From ba4ea2c92fb38ca8dc4fda592a0fb4bd0bdf49b8 Mon Sep 17 00:00:00 2001 From: lbgarber Date: Mon, 8 Mar 2021 15:35:08 -0500 Subject: [PATCH 001/379] Fixed type checking issue with bucket_access in keys_create --- linode_api4/linode_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 33b50bf69..c21594add 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -979,7 +979,7 @@ def keys_create(self, label, bucket_access=None): { "permissions": c.get("permissions"), "bucket_name": c.get("bucket_name"), - "cluster": c.id if "cluster" in c and issubclass(c["cluster"], Base) else c.get("cluster"), + "cluster": c.id if "cluster" in c and issubclass(type(c["cluster"]), Base) else c.get("cluster"), } for c in bucket_access ] From f86ad1e968e71c9dfbee2e5325ff158e62690617 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 9 Mar 2021 07:30:56 -0500 Subject: [PATCH 002/379] Resolved pylint errors --- linode_api4/common.py | 2 +- linode_api4/linode_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/linode_api4/common.py b/linode_api4/common.py index 8db285775..aacdd55c3 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -27,7 +27,7 @@ def load_and_validate_keys(authorized_keys): for k in authorized_keys: accepted_types = ('ssh-dss', 'ssh-rsa', 'ecdsa-sha2-nistp', 'ssh-ed25519') - if any([ t for t in accepted_types if k.startswith(t) ]): + if any([ t for t in accepted_types if k.startswith(t) ]): # pylint: disable=use-a-generator # this looks like a key, cool ret.append(k) else: diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index c21594add..428c0b7c3 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -726,7 +726,7 @@ def user_create(self, email, username, restricted=True): } result = self.client.post('/account/users', data=params) - if not 'email' and 'restricted' and 'username' in result: + if not all([c in result for c in ('email', 'restricted', 'username')]): raise UnexpectedResponseError('Unexpected response when creating user!', json=result) u = User(self.client, result['username'], result) From a74b63bb3d39c350982d74070158917240aaa067 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 9 Mar 2021 09:29:23 -0500 Subject: [PATCH 003/379] Disable that one --- linode_api4/linode_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 428c0b7c3..cb2d92063 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -726,7 +726,7 @@ def user_create(self, email, username, restricted=True): } result = self.client.post('/account/users', data=params) - if not all([c in result for c in ('email', 'restricted', 'username')]): + if not all([c in result for c in ('email', 'restricted', 'username')]): # pylint: disable=use-a-generator raise UnexpectedResponseError('Unexpected response when creating user!', json=result) u = User(self.client, result['username'], result) From ac7c099082fff20f9150a317a8f7e461c7c8c6fb Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 9 Mar 2021 09:42:09 -0500 Subject: [PATCH 004/379] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4b25438ce..78faa6d5b 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.0.0', + version='5.0.1', description='The official python SDK for Linode API v4', long_description=long_description, From 3f3e420ba6e1f21e258fabf9e6be687be71b274c Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 9 Mar 2021 09:46:03 -0500 Subject: [PATCH 005/379] Added a Makefile for more consistent builds --- Makefile | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..ca657d7aa --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +PYTHON ?= python3 + +@PHONEY: clean +clean: + mkdir -p dist + rm dist/* + +@PHONEY: build +build: clean + $(PYTHON) setup.py sdist + $(PYTHON) setup.py bdist_wheel + + +@PHONEY: release +release: build + twine upload dist/* From 75fe3677bdbea393c55d6011e67cf5c7897dd0ce Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Thu, 8 Apr 2021 15:48:32 +0200 Subject: [PATCH 006/379] linode_client: cite private_ip param in instance_create Source: https://www.linode.com/docs/api/linode-instances/#linode-create__request-body-schema --- linode_api4/linode_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index cb2d92063..4ac864e90 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -222,6 +222,9 @@ def instance_create(self, ltype, region, image=None, tags included do not exist, they will be created as part of this operation. :type tags: list[str] + :param private_ip: Whether the new Instance should have private networking + enabled and assigned a private IPv4 address. + :type private_ip: bool :returns: A new Instance object, or a tuple containing the new Instance and the generated password. From 4747b4a094fe2de2cd2453abeff6f944786e4dea Mon Sep 17 00:00:00 2001 From: lbgarber Date: Mon, 26 Apr 2021 15:04:29 -0400 Subject: [PATCH 007/379] Add firewalls endpoints Add example for Firewall usage --- docs/linode_api4/objects/models.rst | 9 ++ linode_api4/linode_client.py | 72 ++++++++++++++++ linode_api4/login_client.py | 14 ++++ linode_api4/objects/__init__.py | 1 + linode_api4/objects/firewall.py | 71 ++++++++++++++++ test/fixtures/networking_firewalls.json | 21 +++++ test/fixtures/networking_firewalls_123.json | 14 ++++ .../networking_firewalls_123_devices.json | 18 ++++ .../networking_firewalls_123_devices_123.json | 11 +++ .../networking_firewalls_123_rules.json | 6 ++ test/linode_client_test.py | 36 ++++++++ test/objects/firewall_test.py | 84 +++++++++++++++++++ 12 files changed, 357 insertions(+) create mode 100644 linode_api4/objects/firewall.py create mode 100644 test/fixtures/networking_firewalls.json create mode 100644 test/fixtures/networking_firewalls_123.json create mode 100644 test/fixtures/networking_firewalls_123_devices.json create mode 100644 test/fixtures/networking_firewalls_123_devices_123.json create mode 100644 test/fixtures/networking_firewalls_123_rules.json create mode 100644 test/objects/firewall_test.py diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 2a3b704ce..366e56e70 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -23,6 +23,15 @@ Domain Models :undoc-members: :inherited-members: +Firewall Models +------------ + +.. automodule:: linode_api4.objects.firewall + :members: + :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name + :undoc-members: + :inherited-members: + Image Models ------------ diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index cb2d92063..bd3010927 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -733,6 +733,78 @@ def user_create(self, email, username, restricted=True): return u class NetworkingGroup(Group): + def firewalls(self, *filters): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + Retrieves the Firewalls your user has access to. + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Firewalls the acting user can access. + :rtype: PaginatedList of Firewall + """ + return self.client._get_and_filter(Firewall, *filters) + + def firewall_create(self, label, rules, **kwargs): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + Creates a new Firewall, either in the given Region or + attached to the given Instance. + + :param label: The label for the new Firewall. + :type label: str + :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. + :type rules: dict + + :returns: The new Firewall. + :rtype: Firewall + + Example usage:: + + rules = { + 'outbound': [ + { + 'action': 'ACCEPT', + 'addresses': { + 'ipv4': [ + '0.0.0.0/0' + ], + 'ipv6': [ + "ff00::/8" + ] + }, + 'description': 'Allow HTTP out.', + 'label': 'allow-http-out', + 'ports': '80', + 'protocol': 'TCP' + } + ], + 'outbound_policy': 'DROP', + 'inbound': [], + 'inbound_policy': 'DROP' + } + + firewall = client.networking.firewall_create('my-firewall', rules) + + .. _Firewalls Documentation: https://www.linode.com/docs/api/networking/#firewall-create__request-body-schema + """ + + params = { + 'label': label, + 'rules': rules, + } + params.update(kwargs) + + result = self.client.post('/networking/firewalls', data=params) + + if not 'id' in result: + raise UnexpectedResponseError('Unexpected response when creating Firewall!', json=result) + + f = Firewall(self, result['id'], result) + return f + def ips(self, *filters): return self.client._get_and_filter(IPAddress, *filters) diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 874b3bbd7..d9d219ca8 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -125,6 +125,19 @@ def __repr__(self): return "ips:*" return "ips:{}".format(self.name) + class Firewalls(Enum): + """ + Access to Firewalls + """ + read_only = 0 + read_write = 1 + all = 2 + + def __repr__(self): + if(self.name == 'all'): + return "firewall:*" + return "firewall:{}".format(self.name) + class Tickets(Enum): """ Access to view, open, and respond to Support Tickets @@ -234,6 +247,7 @@ def __repr__(self): 'users': Users, 'tokens': Tokens, 'ips': IPs, + 'firewall': Firewalls, 'tickets': Tickets, 'clients': Clients, 'account': Account, diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index bf30e1ec5..3a4541448 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,6 +6,7 @@ from .linode import * from .volume import Volume from .domain import * +from .firewall import * from .account import * from .networking import * from .nodebalancer import * diff --git a/linode_api4/objects/firewall.py b/linode_api4/objects/firewall.py new file mode 100644 index 000000000..eb4b02878 --- /dev/null +++ b/linode_api4/objects/firewall.py @@ -0,0 +1,71 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects import Base, DerivedBase, Property + + +class FirewallDevice(DerivedBase): + api_endpoint = '/networking/firewalls/{firewall_id}/devices/{id}' + derived_url_path = 'devices' + parent_id_name = 'firewall_id' + + properties = { + 'created': Property(filterable=True, is_datetime=True), + 'updated': Property(filterable=True, is_datetime=True), + 'entity': Property(), + 'id': Property(identifier=True) + } + + +class Firewall(Base): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + """ + + api_endpoint = "/networking/firewalls/{id}" + + properties = { + 'id': Property(identifier=True), + 'label': Property(mutable=True, filterable=True), + 'tags': Property(mutable=True, filterable=True), + 'status': Property(mutable=True), + 'created': Property(filterable=True, is_datetime=True), + 'updated': Property(filterable=True, is_datetime=True), + 'devices': Property(derived_class=FirewallDevice), + } + + @property + def rules(self): + """ + Returns the JSON rules for this Firewall + """ + return self._client.get('{}/rules'.format(self.api_endpoint), model=self) + + def update_rules(self, rules): + """ + Sets the JSON rules for this Firewall + """ + return self._client.put('{}/rules'.format(self.api_endpoint), model=self, data=rules) + + def device_create(self, id, type, **kwargs): + """ + Creates and attaches a device to this Firewall + + :param id: The ID of the entity to create a device for. + :type id: int + + :param type: The type of entity the device is being created for. (`linode`) + :type type: str + """ + params = { + 'id': id, + 'type': type, + } + params.update(kwargs) + + result = self._client.post("{}/devices".format(Firewall.api_endpoint), model=self, data=params) + self.invalidate() + + if not 'id' in result: + raise UnexpectedResponseError('Unexpected response creating device!', json=result) + + c = FirewallDevice(self._client, result['id'], self.id, result) + return c diff --git a/test/fixtures/networking_firewalls.json b/test/fixtures/networking_firewalls.json new file mode 100644 index 000000000..0bd9660f1 --- /dev/null +++ b/test/fixtures/networking_firewalls.json @@ -0,0 +1,21 @@ +{ + "data":[ + { + "id":123, + "label":"test-firewall-1", + "created":"2018-01-01T00:01:01", + "updated":"2018-01-01T00:01:01", + "status":"enabled", + "rules":{ + "outbound":[], + "outbound_policy":"DROP", + "inbound":[], + "inbound_policy":"DROP" + }, + "tags":[] + } + ], + "page":1, + "pages":1, + "results":1 +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123.json b/test/fixtures/networking_firewalls_123.json new file mode 100644 index 000000000..c34a3991e --- /dev/null +++ b/test/fixtures/networking_firewalls_123.json @@ -0,0 +1,14 @@ +{ + "id":123, + "label":"test-firewall-1", + "created":"2018-01-01T00:01:01", + "updated":"2018-01-01T00:01:01", + "status":"enabled", + "rules":{ + "outbound":[], + "outbound_policy":"DROP", + "inbound":[], + "inbound_policy":"DROP" + }, + "tags":[] +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices.json b/test/fixtures/networking_firewalls_123_devices.json new file mode 100644 index 000000000..ae4efe2d0 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_devices.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": "my-linode", + "type": "linode", + "url": "/v4/linode/instances/123" + }, + "id": 123, + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices_123.json b/test/fixtures/networking_firewalls_123_devices_123.json new file mode 100644 index 000000000..ce536c684 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_devices_123.json @@ -0,0 +1,11 @@ +{ + "created":"2018-01-01T00:01:01", + "entity":{ + "id":123, + "label":"my-linode", + "type":"linode", + "url":"/v4/linode/instances/123" + }, + "id":123, + "updated":"2018-01-02T00:01:01" +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_rules.json b/test/fixtures/networking_firewalls_123_rules.json new file mode 100644 index 000000000..43c8af4dc --- /dev/null +++ b/test/fixtures/networking_firewalls_123_rules.json @@ -0,0 +1,6 @@ +{ + "inbound": [], + "inbound_policy": "DROP", + "outbound": [], + "outbound_policy": "DROP" +} \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index e567a27d5..200ab97f6 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -533,3 +533,39 @@ def test_keys_create(self): self.assertEqual(m.call_url, '/object-storage/keys') self.assertEqual(m.call_data, {"label":"object-storage-key-1"}) + +class NetworkingGroupTest(ClientBaseCase): + def test_firewall_create(self): + with self.mock_post('networking/firewalls/123') as m: + rules = { + 'outbound': [], + 'outbound_policy': 'DROP', + 'inbound': [], + 'inbound_policy': 'DROP' + } + + f = self.client.networking.firewall_create('test-firewall-1', rules, + status='enabled') + + self.assertIsNotNone(f) + + self.assertEqual(m.call_url, '/networking/firewalls') + self.assertEqual(m.method, 'post') + + self.assertEqual(f.id, 123) + self.assertEqual(m.call_data, { + 'label': 'test-firewall-1', + 'status': 'enabled', + 'rules': rules + }) + + def test_get_firewalls(self): + """ + Tests that firewalls can be retrieved + """ + f = self.client.networking.firewalls() + + self.assertEqual(len(f), 1) + firewall = f[0] + + self.assertEqual(firewall.id, 123) \ No newline at end of file diff --git a/test/objects/firewall_test.py b/test/objects/firewall_test.py new file mode 100644 index 000000000..38693b370 --- /dev/null +++ b/test/objects/firewall_test.py @@ -0,0 +1,84 @@ +from test.base import ClientBaseCase + +from linode_api4.objects import Firewall, FirewallDevice + +class FirewallTest(ClientBaseCase): + """ + Tests methods of the Firewall class + """ + def test_get_rules(self): + """ + Test that the rules can be retrieved from a Firewall + """ + firewall = Firewall(self.client, 123) + rules = firewall.rules + + self.assertEqual(len(rules['inbound']), 0) + self.assertEqual(rules['inbound_policy'], 'DROP') + self.assertEqual(len(rules['outbound']), 0) + self.assertEqual(rules['outbound_policy'], 'DROP') + + def test_update_rules(self): + """ + Test that the rules can be updated for a Firewall + """ + + firewall = Firewall(self.client, 123) + + with self.mock_put('networking/firewalls/123/rules') as m: + new_rules = { + 'inbound': [ + { + 'action': 'ACCEPT', + 'addresses': { + 'ipv4': [ + '0.0.0.0/0' + ], + 'ipv6': [ + "ff00::/8" + ] + }, + 'description': 'A really cool firewall rule.', + 'label': 'really-cool-firewall-rule', + 'ports': '80', + 'protocol': 'TCP' + } + ], + 'inbound_policy': 'ALLOW', + 'outbound': [], + 'outbound_policy': 'ALLOW' + } + + firewall.update_rules(new_rules) + + self.assertEqual(m.method, 'put') + self.assertEqual(m.call_url, '/networking/firewalls/123/rules') + + self.assertEqual(m.call_data, new_rules) + + +class FirewallDevicesTest(ClientBaseCase): + """ + Tests methods of Firewall devices + """ + def test_get_devices(self): + """ + Tests that devices can be pulled from a firewall + """ + firewall = Firewall(self.client, 123) + self.assertEqual(len(firewall.devices), 1) + + def test_get_device(self): + """ + Tests that a device is loaded correctly by ID + """ + device = FirewallDevice(self.client, 123, 123) + self.assertEqual(device._populated, False) + + self.assertEqual(device.id, 123) + self.assertEqual(device.entity.id, 123) + self.assertEqual(device.entity.label, 'my-linode') + self.assertEqual(device.entity.type, 'linode') + self.assertEqual(device.entity.url, '/v4/linode/instances/123') + + self.assertEqual(device._populated, True) From dc1f773c41a9e7eece61b5dfde9ae624e0b9b669 Mon Sep 17 00:00:00 2001 From: lbgarber Date: Tue, 27 Apr 2021 14:20:49 -0400 Subject: [PATCH 008/379] Add VLANs and interfaces Add beta warning --- linode_api4/linode_client.py | 7 +++ linode_api4/objects/linode.py | 1 + linode_api4/objects/networking.py | 16 +++++++ .../linode_instances_123_configs.json | 7 +++ .../linode_instances_123_configs_456789.json | 43 +++++++++++++++++++ test/fixtures/networking_vlans.json | 16 +++++++ test/linode_client_test.py | 19 +++++++- test/objects/linode_test.py | 32 ++++++++++++++ 8 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/linode_instances_123_configs_456789.json create mode 100644 test/fixtures/networking_vlans.json diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index cb2d92063..fc2cdb314 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -742,6 +742,12 @@ def ipv6_ranges(self, *filters): def ipv6_pools(self, *filters): return self.client._get_and_filter(IPv6Pool, *filters) + def vlans(self, *filters): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + """ + return self.client._get_and_filter(VLAN, *filters) + def ips_assign(self, region, *assignments): """ Redistributes :any:`IP Addressees` within a single region. @@ -851,6 +857,7 @@ def ips_share(self, linode, *ips): linode.invalidate() # clear the Instance's shared IPs + class SupportGroup(Group): def tickets(self, *filters): return self.client._get_and_filter(SupportTicket, *filters) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 4c3a58047..069e59a2e 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -182,6 +182,7 @@ class Config(DerivedBase): "run_level": Property(mutable=True, filterable=True), "virt_mode": Property(mutable=True, filterable=True), "memory_limit": Property(mutable=True, filterable=True), + "interfaces": Property(mutable=True), } def _populate(self, json): diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index d8dcef1b5..60ed6a128 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -54,3 +54,19 @@ def to(self, linode): if not isinstance(linode, Instance): raise ValueError("IP Address can only be assigned to a Linode!") return { "address": self.address, "linode_id": linode.id } + + +class VLAN(Base): + """ + .. note:: At this time, the Linode API only supports listing VLANs. + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + """ + api_endpoint = '/networking/vlans/{}' + id_attribute = 'label' + + properties = { + 'label': Property(identifier=True), + 'created': Property(is_datetime=True), + 'linodes': Property(filterable=True), + 'region': Property(slug_relationship=Region, filterable=True) + } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs.json b/test/fixtures/linode_instances_123_configs.json index 7c343f480..a45ef1dd8 100644 --- a/test/fixtures/linode_instances_123_configs.json +++ b/test/fixtures/linode_instances_123_configs.json @@ -14,6 +14,13 @@ "created": "2014-10-07T20:04:00", "memory_limit": 0, "id": 456789, + "interfaces": [ + { + "ipam_address": "0.0.0.0/24", + "label": "test-interface", + "purpose": "vlan" + } + ], "run_level": "default", "initrd": null, "virt_mode": "paravirt", diff --git a/test/fixtures/linode_instances_123_configs_456789.json b/test/fixtures/linode_instances_123_configs_456789.json new file mode 100644 index 000000000..b19cba3af --- /dev/null +++ b/test/fixtures/linode_instances_123_configs_456789.json @@ -0,0 +1,43 @@ +{ + "root_device":"/dev/sda", + "comments":"", + "helpers":{ + "updatedb_disabled":true, + "modules_dep":true, + "devtmpfs_automount":true, + "distro":true, + "network":false + }, + "label":"My Ubuntu 17.04 LTS Profile", + "created":"2014-10-07T20:04:00", + "memory_limit":0, + "id":456789, + "interfaces":[ + { + "ipam_address":"0.0.0.0/24", + "label":"test-interface", + "purpose":"vlan" + } + ], + "run_level":"default", + "initrd":null, + "virt_mode":"paravirt", + "kernel":"linode/latest-64bit", + "updated":"2014-10-07T20:04:00", + "devices":{ + "sda":{ + "disk_id":12345, + "volume_id":null + }, + "sdc":null, + "sde":null, + "sdh":null, + "sdg":null, + "sdb":{ + "disk_id":12346, + "volume_id":null + }, + "sdf":null, + "sdd":null + } +} \ No newline at end of file diff --git a/test/fixtures/networking_vlans.json b/test/fixtures/networking_vlans.json new file mode 100644 index 000000000..c42094777 --- /dev/null +++ b/test/fixtures/networking_vlans.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "created": "2020-01-01T00:01:01", + "label": "vlan-test", + "linodes": [ + 111, + 222 + ], + "region": "us-southeast" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index e567a27d5..74168fddc 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -270,7 +270,6 @@ def test_instance_create_with_image(self): "root_pass": pw, }) - class LongviewGroupTest(ClientBaseCase): """ Tests methods of the LongviewGroup @@ -533,3 +532,21 @@ def test_keys_create(self): self.assertEqual(m.call_url, '/object-storage/keys') self.assertEqual(m.call_data, {"label":"object-storage-key-1"}) + +class NetworkingGroupTest(ClientBaseCase): + """ + Tests for the NetworkingGroup + """ + def test_get_vlans(self): + """ + Tests that Object Storage Clusters can be retrieved + """ + vlans = self.client.networking.vlans() + + self.assertEqual(len(vlans), 1) + self.assertEqual(vlans[0].label, 'vlan-test') + self.assertEqual(vlans[0].region.id, 'us-southeast') + + self.assertEqual(len(vlans[0].linodes), 2) + self.assertEqual(vlans[0].linodes[0], 111) + self.assertEqual(vlans[0].linodes[1], 222) \ No newline at end of file diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index f1a614ab1..4a269fec6 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -279,6 +279,38 @@ def test_resize(self): self.assertEqual(m.call_data, {"size": 1000}) +class ConfigTest(ClientBaseCase): + """ + Tests for the Config object + """ + + def test_update_interfaces(self): + """ + Tests that a configs interfaces update correctly + """ + + json = self.client.get('/linode/instances/123/configs/456789') + config = Config(self.client, 456789, 123, json=json) + + with self.mock_put('/linode/instances/123/configs/456789') as m: + new_interfaces = [ + { + 'purpose': 'public' + }, + { + 'purpose': 'vlan', + 'label': 'cool-vlan' + } + ] + + config.interfaces = new_interfaces + + config.save() + + self.assertEqual(m.call_url, '/linode/instances/123/configs/456789') + self.assertEqual(m.call_data.get('interfaces'), new_interfaces) + + class TypeTest(ClientBaseCase): def test_get_types(self): """ From d273cb4676d4e79e4777f810d426e4fc245f8086 Mon Sep 17 00:00:00 2001 From: lbgarber Date: Thu, 29 Apr 2021 10:20:40 -0400 Subject: [PATCH 009/379] Firewall improvements --- docs/linode_api4/objects/models.rst | 9 ---- linode_api4/linode_client.py | 2 +- linode_api4/objects/__init__.py | 1 - linode_api4/objects/firewall.py | 71 ----------------------------- linode_api4/objects/networking.py | 66 ++++++++++++++++++++++++++- test/objects/firewall_test.py | 8 ++-- 6 files changed, 70 insertions(+), 87 deletions(-) delete mode 100644 linode_api4/objects/firewall.py diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 366e56e70..2a3b704ce 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -23,15 +23,6 @@ Domain Models :undoc-members: :inherited-members: -Firewall Models ------------- - -.. automodule:: linode_api4.objects.firewall - :members: - :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name - :undoc-members: - :inherited-members: - Image Models ------------ diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index bd3010927..9e771dc90 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -802,7 +802,7 @@ def firewall_create(self, label, rules, **kwargs): if not 'id' in result: raise UnexpectedResponseError('Unexpected response when creating Firewall!', json=result) - f = Firewall(self, result['id'], result) + f = Firewall(self.client, result['id'], result) return f def ips(self, *filters): diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 3a4541448..bf30e1ec5 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,7 +6,6 @@ from .linode import * from .volume import Volume from .domain import * -from .firewall import * from .account import * from .networking import * from .nodebalancer import * diff --git a/linode_api4/objects/firewall.py b/linode_api4/objects/firewall.py deleted file mode 100644 index eb4b02878..000000000 --- a/linode_api4/objects/firewall.py +++ /dev/null @@ -1,71 +0,0 @@ -from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, DerivedBase, Property - - -class FirewallDevice(DerivedBase): - api_endpoint = '/networking/firewalls/{firewall_id}/devices/{id}' - derived_url_path = 'devices' - parent_id_name = 'firewall_id' - - properties = { - 'created': Property(filterable=True, is_datetime=True), - 'updated': Property(filterable=True, is_datetime=True), - 'entity': Property(), - 'id': Property(identifier=True) - } - - -class Firewall(Base): - """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - """ - - api_endpoint = "/networking/firewalls/{id}" - - properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True, filterable=True), - 'tags': Property(mutable=True, filterable=True), - 'status': Property(mutable=True), - 'created': Property(filterable=True, is_datetime=True), - 'updated': Property(filterable=True, is_datetime=True), - 'devices': Property(derived_class=FirewallDevice), - } - - @property - def rules(self): - """ - Returns the JSON rules for this Firewall - """ - return self._client.get('{}/rules'.format(self.api_endpoint), model=self) - - def update_rules(self, rules): - """ - Sets the JSON rules for this Firewall - """ - return self._client.put('{}/rules'.format(self.api_endpoint), model=self, data=rules) - - def device_create(self, id, type, **kwargs): - """ - Creates and attaches a device to this Firewall - - :param id: The ID of the entity to create a device for. - :type id: int - - :param type: The type of entity the device is being created for. (`linode`) - :type type: str - """ - params = { - 'id': id, - 'type': type, - } - params.update(kwargs) - - result = self._client.post("{}/devices".format(Firewall.api_endpoint), model=self, data=params) - self.invalidate() - - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating device!', json=result) - - c = FirewallDevice(self._client, result['id'], self.id, result) - return c diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index d8dcef1b5..b47107497 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,4 +1,5 @@ -from linode_api4.objects import Base, Property, Region +from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects import Base, DerivedBase, Property, Region class IPv6Pool(Base): @@ -54,3 +55,66 @@ def to(self, linode): if not isinstance(linode, Instance): raise ValueError("IP Address can only be assigned to a Linode!") return { "address": self.address, "linode_id": linode.id } + + +class FirewallDevice(DerivedBase): + api_endpoint = '/networking/firewalls/{firewall_id}/devices/{id}' + derived_url_path = 'devices' + parent_id_name = 'firewall_id' + + properties = { + 'created': Property(filterable=True, is_datetime=True), + 'updated': Property(filterable=True, is_datetime=True), + 'entity': Property(), + 'id': Property(identifier=True) + } + + +class Firewall(Base): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + """ + + api_endpoint = "/networking/firewalls/{id}" + + properties = { + 'id': Property(identifier=True), + 'label': Property(mutable=True, filterable=True), + 'tags': Property(mutable=True, filterable=True), + 'status': Property(mutable=True), + 'created': Property(filterable=True, is_datetime=True), + 'updated': Property(filterable=True, is_datetime=True), + 'devices': Property(derived_class=FirewallDevice), + 'rules': Property(), + } + def update_rules(self, rules): + """ + Sets the JSON rules for this Firewall + """ + self._client.put('{}/rules'.format(self.api_endpoint), model=self, data=rules) + self.invalidate() + + def device_create(self, id, type='linode', **kwargs): + """ + Creates and attaches a device to this Firewall + + :param id: The ID of the entity to create a device for. + :type id: int + + :param type: The type of entity the device is being created for. (`linode`) + :type type: str + """ + params = { + 'id': id, + 'type': type, + } + params.update(kwargs) + + result = self._client.post("{}/devices".format(Firewall.api_endpoint), model=self, data=params) + self.invalidate() + + if not 'id' in result: + raise UnexpectedResponseError('Unexpected response creating device!', json=result) + + c = FirewallDevice(self._client, result['id'], self.id, result) + return c diff --git a/test/objects/firewall_test.py b/test/objects/firewall_test.py index 38693b370..0a0f27448 100644 --- a/test/objects/firewall_test.py +++ b/test/objects/firewall_test.py @@ -13,10 +13,10 @@ def test_get_rules(self): firewall = Firewall(self.client, 123) rules = firewall.rules - self.assertEqual(len(rules['inbound']), 0) - self.assertEqual(rules['inbound_policy'], 'DROP') - self.assertEqual(len(rules['outbound']), 0) - self.assertEqual(rules['outbound_policy'], 'DROP') + self.assertEqual(len(rules.inbound), 0) + self.assertEqual(rules.inbound_policy, 'DROP') + self.assertEqual(len(rules.outbound), 0) + self.assertEqual(rules.outbound_policy, 'DROP') def test_update_rules(self): """ From 5c52c3b46628fd623853ece934d5e1578885539f Mon Sep 17 00:00:00 2001 From: Will Smith Date: Thu, 29 Apr 2021 11:20:57 -0400 Subject: [PATCH 010/379] Added ConfigInterface class to encapsulate Interface data This is intended to be used like so: ```python In [7]: c.interfaces = [ ...: ConfigInterface("public"), ...: ConfigInterface("vlan", label="new", ipam_address="10.0.0.0/24"), ...: ] In [8]: c.save() Out[8]: True In [9]: c.interfaces Out[9]: [Public Interface, Interface new; purpose: vlan; ipam_address: 10.0.0.0/24] ``` This makes it a lot easier to create interfaces, compared to using dicts. Dicts are still supported though, although all interfaces will be rendered into the ConfigInterface class now. --- linode_api4/objects/linode.py | 66 ++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 069e59a2e..ac77dbe19 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -162,6 +162,45 @@ def _populate(self, json): type_class = FilterableAttribute('class') +class ConfigInterface: + """ + This is a helper class used to populate 'interfaces' in the Config calss + below. + """ + def __init__(self, purpose, label="", ipam_address=""): + """ + Creates a new ConfigInterface + """ + #: The Label for the VLAN this interface is connected to. Blank for public + #: interfaces. + self.label = label + + #: The IPAM Address this interface will bring up. Blank for public interfaces. + self.ipam_address = ipam_address + + #: The purpose of this interface. "public" means this interface can access + #: the internet, "vlan" means it is a VLAN interface. + self.purpose = purpose + + def __repr__(self): + if self.purpose == "public": + return "Public Interface" + return "Interface {}; purpose: {}; ipam_address: {}".format( + self.label, self.purpose, self.ipam_address + ) + + def _serialize(self): + """ + Returns this object as a dict + """ + return { + "label": self.label, + "ipam_address": self.ipam_address, + "purpose": self.purpose, + } + + + class Config(DerivedBase): api_endpoint="/linode/instances/{linode_id}/configs/{id}" derived_url_path="configs" @@ -182,7 +221,7 @@ class Config(DerivedBase): "run_level": Property(mutable=True, filterable=True), "virt_mode": Property(mutable=True, filterable=True), "memory_limit": Property(mutable=True, filterable=True), - "interfaces": Property(mutable=True), + "interfaces": Property(mutable=True), # gets setup in _populate below } def _populate(self, json): @@ -211,6 +250,31 @@ def _populate(self, json): self._set('devices', MappedObject(**devices)) + interfaces = [] + if "interfaces" in json: + interfaces = [ + ConfigInterface(c["purpose"], label=c["label"], ipam_address=c["ipam_address"]) + for c in json["interfaces"] + ] + + self._set("interfaces", interfaces) + + def _serialize(self): + """ + Overrides _serialize to transform interfaces into json + """ + partial = DerivedBase._serialize(self) + interfaces = [] + + for c in self.interfaces: + if isinstance(c, ConfigInterface): + interfaces.append(c._seiralize()) + else: + interfaces.append(c) + + partial["interfaces"] = interfaces + return partial + class Instance(Base): api_endpoint = '/linode/instances/{id}' From a6673a2c9a1639423629d4be846220f4875ee323 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Thu, 29 Apr 2021 11:25:46 -0400 Subject: [PATCH 011/379] Update linode_api4/linode_client.py Co-authored-by: William Smith --- linode_api4/linode_client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index fc2cdb314..89ef0f625 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -745,6 +745,10 @@ def ipv6_pools(self, *filters): def vlans(self, *filters): """ .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + Returns a list of VLANs on your account. + + :returns: A Paginated List of VLANs on your account. + :rtype: PaginatedList of VLAN """ return self.client._get_and_filter(VLAN, *filters) From d18f02d9f3f834d90b8da66f7475acbdbdf36bab Mon Sep 17 00:00:00 2001 From: Will Smith Date: Thu, 29 Apr 2021 11:56:40 -0400 Subject: [PATCH 012/379] Bumped version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 78faa6d5b..02db453d3 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.0.1', + version='5.1.0', description='The official python SDK for Linode API v4', long_description=long_description, From 63857bf6b44b4fde79f2641d7d53563fac2aa706 Mon Sep 17 00:00:00 2001 From: lbgarber Date: Mon, 3 May 2021 10:51:51 -0400 Subject: [PATCH 013/379] Fix typo in config serialize method --- linode_api4/objects/linode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index ac77dbe19..ccdbbb8d8 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -268,7 +268,7 @@ def _serialize(self): for c in self.interfaces: if isinstance(c, ConfigInterface): - interfaces.append(c._seiralize()) + interfaces.append(c._serialize()) else: interfaces.append(c) From 55a804d7ae9c208965b6fb759d772cdcebade896 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Mon, 3 May 2021 10:58:34 -0400 Subject: [PATCH 014/379] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 02db453d3..2d6e1ac2d 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.1.0', + version='5.1.1', description='The official python SDK for Linode API v4', long_description=long_description, From b8e6d7b51ec02100c6791e06ea353475c5a5c7b3 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 18 May 2021 15:08:00 -0400 Subject: [PATCH 015/379] fix: Capture cluster_id correctly for LKENodePools Closes #203 --- linode_api4/objects/lke.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index abfd3282a..b25cb9af7 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -36,6 +36,7 @@ def __init__(self, client, json): #: The Status of this Node Pool Node self.status = json.get("status") + class LKENodePool(DerivedBase): """ An LKE Node Pool describes a pool of Linode Instances that exist within an @@ -43,7 +44,7 @@ class LKENodePool(DerivedBase): """ api_endpoint = "/lke/clusters/{cluster_id}/pools/{id}" derived_url_path = 'pools' - parent_id = "linode_id" + parent_id_name = "cluster_id" properties = { "id": Property(identifier=True), From d658a99e8bf63af4f88201f9bb7b969351a65f77 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 18 May 2021 15:22:30 -0400 Subject: [PATCH 016/379] Bump version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2d6e1ac2d..e4a918190 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.1.1', + version='5.1.2', description='The official python SDK for Linode API v4', long_description=long_description, From cc8f90b2064f40ddad635907b5e7468a05f97c05 Mon Sep 17 00:00:00 2001 From: Billy Thompson Date: Tue, 18 May 2021 15:44:21 -0400 Subject: [PATCH 017/379] resolve supernatural missing method weridness --- linode_api4/linode_client.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 2a9c7f6a9..20fe25230 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -2,6 +2,7 @@ import logging from datetime import datetime import os +import time import pkg_resources import requests @@ -1114,7 +1115,7 @@ def cancel(self): class LinodeClient: - def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None): + def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_backoff=0): """ The main interface to the Linode API. @@ -1135,12 +1136,14 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, can be found in the API docs, but at time of writing are between 25 and 500. :type page_size: int + :type retry_rate_limit_backoff: int """ self.base_url = base_url self._add_user_agent = user_agent self.token = token self.session = requests.Session() self.page_size = page_size + self.retry_rate_limit_backoff = retry_rate_limit_backoff #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information @@ -1242,10 +1245,22 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): body = json.dumps(data) response = method(url, headers=headers, data=body) - - warning = response.headers.get('Warning', None) - if warning: - logger.warning('Received warning from server: {}'.format(warning)) + # retry on 429 response + max_retries = 5 if self.retry_rate_limit_backoff else 0 + for attempt in range(max_retries): + response = method(url, headers=headers, data=body) + + warning = response.headers.get('Warning', None) + if warning: + logger.warning('Received warning from server: {}'.format(warning)) + + if self.retry_rate_limit_backoff and response.status_code == 429: + try: + time.sleep(self.retry_rate_limit_backoff) + except TypeError: + print("Integer is required for retry rate limit backoff!") + else: + break if 399 < response.status_code < 600: j = None From abe4123877538dfd85da1179e00db8385dffc3a3 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Thu, 20 May 2021 09:27:14 -0400 Subject: [PATCH 018/379] Added tests, changed validation, and added logging --- linode_api4/linode_client.py | 24 +++++-- test/linode_client_test.py | 128 ++++++++++++++++++++++++++++++++++- 2 files changed, 143 insertions(+), 9 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 20fe25230..647644562 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1115,7 +1115,7 @@ def cancel(self): class LinodeClient: - def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_backoff=0): + def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_backoff=None): """ The main interface to the Linode API. @@ -1136,6 +1136,9 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, can be found in the API docs, but at time of writing are between 25 and 500. :type page_size: int + :param retry_rate_limit_backoff: If given, 429 responses will be automatically + retried up to 5 times with the given interval, + in seconds, between attempts. :type retry_rate_limit_backoff: int """ self.base_url = base_url @@ -1145,6 +1148,13 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, self.page_size = page_size self.retry_rate_limit_backoff = retry_rate_limit_backoff + # make sure we got a sane backoff + if self.retry_rate_limit_backoff is not None: + if not isinstance(self.retry_rate_limit_backoff, int): + raise ValueError("retry_rate_limit_backoff must be an int!") + if self.retry_rate_limit_backoff < 1: + raise ValueError("retry_rate_limit_backoff must not be less than 1!") + #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information self.linode = LinodeGroup(self) @@ -1244,9 +1254,8 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): if data is not None: body = json.dumps(data) - response = method(url, headers=headers, data=body) # retry on 429 response - max_retries = 5 if self.retry_rate_limit_backoff else 0 + max_retries = 5 if self.retry_rate_limit_backoff else 1 for attempt in range(max_retries): response = method(url, headers=headers, data=body) @@ -1254,11 +1263,12 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): if warning: logger.warning('Received warning from server: {}'.format(warning)) + # if we were configured to retry 429s, and we got a 429, sleep briefly and then retry if self.retry_rate_limit_backoff and response.status_code == 429: - try: - time.sleep(self.retry_rate_limit_backoff) - except TypeError: - print("Integer is required for retry rate limit backoff!") + logger.warning("Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format( + self.retry_rate_limit_backoff, attempt, max_retries, + )) + time.sleep(self.retry_rate_limit_backoff) else: break diff --git a/test/linode_client_test.py b/test/linode_client_test.py index f54e8b52c..cd4798868 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -1,8 +1,10 @@ from datetime import datetime +from unittest import TestCase +from unittest.mock import MagicMock from test.base import ClientBaseCase -from linode_api4 import LongviewSubscription +from linode_api4 import LongviewSubscription, LinodeClient, ApiError class LinodeClientGeneralTest(ClientBaseCase): @@ -584,4 +586,126 @@ def test_get_firewalls(self): self.assertEqual(len(f), 1) firewall = f[0] - self.assertEqual(firewall.id, 123) \ No newline at end of file + self.assertEqual(firewall.id, 123) + + +class LinodeClientRateLimitRetryTest(TestCase): + """ + Tests for rate limiting errors. + + .. warning:: + This test class _does not_ follow normal testing conventions for this project, + as requests are not automatically mocked. Only add tests to this class if they + pertain to the 429 retry logic, and make sure you mock the requests calls yourself + (or else they will make real requests and those won't work). + """ + def setUp(self): + self.client = LinodeClient("testing", base_url="/", retry_rate_limit_backoff=1) + # sidestep the validation to do immediate retries so tests aren't slow + self.client.retry_rate_limit_backoff = 0.1 + + def _get_mock_response(self, response_code): + """ + Helper function to return a mock response + """ + ret = MagicMock() + ret.status_code = response_code + ret.json.return_value = {} + + return ret + + def test_retry_429s(self): + """ + Tests that 429 responses are automatically retried + """ + called = 0 + def test_method(*args, **kwargs): + nonlocal called + called += 1 + if called < 2: + return self._get_mock_response(429) + return self._get_mock_response(200) + + response = self.client._api_call('/test', method=test_method) + + # it retried once, got the empty object + assert called == 2 + assert response == {}, response + + def test_retry_max_attempts(self): + """ + Tests that a request will fail after 5 429 responses in a row + """ + called = 0 + def test_method(*args, **kwargs): + nonlocal called + called += 1 + return self._get_mock_response(429) + + try: + response = self.client._api_call('/test', method=test_method) + assert False, "Unexpectedly did not raise ApiError!" + except ApiError as e: + assert e.status == 429 + + # it tried 5 times + assert called == 5 + + def test_api_error_with_retry(self): + """ + Tests that a 300+ response still raises an ApiError even if retries are + enabled + """ + called = 0 + def test_method(*args, **kwargs): + nonlocal called + called += 1 + return self._get_mock_response(400) + + try: + response = self.client._api_call('/test', method=test_method) + assert False, "Unexpectedly did not raise ApiError!" + except ApiError as e: + assert e.status == 400 + + # it tried 5 times + assert called == 1 + + def test_api_error_on_retry(self): + """ + Tests that we'll stop retrying and raise immediately if we get a 300+ + response after a 429 + """ + called = 0 + def test_method(*args, **kwargs): + nonlocal called + called += 1 + if called < 2: + return self._get_mock_response(429) + return self._get_mock_response(400) + + try: + response = self.client._api_call('/test', method=test_method) + assert False, "Unexpectedly did not raise ApiError!" + except ApiError as e: + assert e.status == 400 + + # it tried 5 times + assert called == 2 + + def test_works_first_time(self): + """ + Tests that the response is handled correctly if we got a 200 on the first + try + """ + called = 0 + def test_method(*args, **kwargs): + nonlocal called + called += 1 + return self._get_mock_response(200) + + response = self.client._api_call('/test', method=test_method) + + # it tried 5 times + assert called == 1 + assert response == {} From 7db7f72523319ec8aa324095b73546c48e70114d Mon Sep 17 00:00:00 2001 From: William Smith Date: Thu, 20 May 2021 10:21:37 -0400 Subject: [PATCH 019/379] Update linode_api4/linode_client.py Co-authored-by: Josh Sager --- linode_api4/linode_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 647644562..eca97d21c 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1151,7 +1151,7 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, # make sure we got a sane backoff if self.retry_rate_limit_backoff is not None: if not isinstance(self.retry_rate_limit_backoff, int): - raise ValueError("retry_rate_limit_backoff must be an int!") + raise ValueError("retry_rate_limit_backoff must be an int") if self.retry_rate_limit_backoff < 1: raise ValueError("retry_rate_limit_backoff must not be less than 1!") From 894757ae8bc1e0542676b3dad312b6e157f01e10 Mon Sep 17 00:00:00 2001 From: William Smith Date: Thu, 20 May 2021 10:21:42 -0400 Subject: [PATCH 020/379] Update linode_api4/linode_client.py Co-authored-by: Josh Sager --- linode_api4/linode_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index eca97d21c..24b313b16 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1153,7 +1153,7 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, if not isinstance(self.retry_rate_limit_backoff, int): raise ValueError("retry_rate_limit_backoff must be an int") if self.retry_rate_limit_backoff < 1: - raise ValueError("retry_rate_limit_backoff must not be less than 1!") + raise ValueError("retry_rate_limit_backoff must not be less than 1") #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information From afec1497d561bc1f4cb3c1ce3d0d490ec57908f6 Mon Sep 17 00:00:00 2001 From: Billy Thompson Date: Thu, 27 May 2021 11:42:39 -0400 Subject: [PATCH 021/379] changed variable name from backoff to interval --- linode_api4/linode_client.py | 26 +++++++++++++------------- test/linode_client_test.py | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 24b313b16..e25a5f7cb 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1115,7 +1115,7 @@ def cancel(self): class LinodeClient: - def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_backoff=None): + def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_interval=None): """ The main interface to the Linode API. @@ -1136,24 +1136,24 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, can be found in the API docs, but at time of writing are between 25 and 500. :type page_size: int - :param retry_rate_limit_backoff: If given, 429 responses will be automatically + :param retry_rate_limit_interval: If given, 429 responses will be automatically retried up to 5 times with the given interval, in seconds, between attempts. - :type retry_rate_limit_backoff: int + :type retry_rate_limit_interval: int """ self.base_url = base_url self._add_user_agent = user_agent self.token = token self.session = requests.Session() self.page_size = page_size - self.retry_rate_limit_backoff = retry_rate_limit_backoff + self.retry_rate_limit_interval = retry_rate_limit_interval # make sure we got a sane backoff - if self.retry_rate_limit_backoff is not None: - if not isinstance(self.retry_rate_limit_backoff, int): - raise ValueError("retry_rate_limit_backoff must be an int") - if self.retry_rate_limit_backoff < 1: - raise ValueError("retry_rate_limit_backoff must not be less than 1") + if self.retry_rate_limit_interval is not None: + if not isinstance(self.retry_rate_limit_interval, int): + raise ValueError("retry_rate_limit_interval must be an int") + if self.retry_rate_limit_interval < 1: + raise ValueError("retry_rate_limit_interval must not be less than 1") #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information @@ -1255,7 +1255,7 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): body = json.dumps(data) # retry on 429 response - max_retries = 5 if self.retry_rate_limit_backoff else 1 + max_retries = 5 if self.retry_rate_limit_interval else 1 for attempt in range(max_retries): response = method(url, headers=headers, data=body) @@ -1264,11 +1264,11 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): logger.warning('Received warning from server: {}'.format(warning)) # if we were configured to retry 429s, and we got a 429, sleep briefly and then retry - if self.retry_rate_limit_backoff and response.status_code == 429: + if self.retry_rate_limit_interval and response.status_code == 429: logger.warning("Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format( - self.retry_rate_limit_backoff, attempt, max_retries, + self.retry_rate_limit_interval, attempt, max_retries, )) - time.sleep(self.retry_rate_limit_backoff) + time.sleep(self.retry_rate_limit_interval) else: break diff --git a/test/linode_client_test.py b/test/linode_client_test.py index cd4798868..55ff3b38f 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -600,9 +600,9 @@ class LinodeClientRateLimitRetryTest(TestCase): (or else they will make real requests and those won't work). """ def setUp(self): - self.client = LinodeClient("testing", base_url="/", retry_rate_limit_backoff=1) + self.client = LinodeClient("testing", base_url="/", retry_rate_limit_interval=1) # sidestep the validation to do immediate retries so tests aren't slow - self.client.retry_rate_limit_backoff = 0.1 + self.client.retry_rate_limit_interval = 0.1 def _get_mock_response(self, response_code): """ From 0e562bd81a4b04ead8aef1ceb9648a1bb7e6bbaf Mon Sep 17 00:00:00 2001 From: Will Smith Date: Fri, 28 May 2021 08:12:31 -0400 Subject: [PATCH 022/379] Bumped version for 5.2.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e4a918190..b6f8e3c29 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.1.2', + version='5.2.0', description='The official python SDK for Linode API v4', long_description=long_description, From d05a0080551ede6271abb4336fa6f13efd42ef4d Mon Sep 17 00:00:00 2001 From: Will Smith Date: Fri, 16 Jul 2021 08:24:52 -0400 Subject: [PATCH 023/379] bug: Ensure NodeBalancerConfig is loaded before setting ssl data Closes #206 --- linode_api4/objects/nodebalancer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 7a3ec1b95..9924843d4 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -114,6 +114,12 @@ def load_ssl_data(self, cert_file, key_file): :param key_file: A path to the file containing the unpassphrased private key :type key_file: str """ + # access a server-loaded field to ensure this object is loaded from the + # server before setting values. Failing to do this can cause an unloaded + # object to overwrite these values on a subsequent load, which happens to + # occur on a save() + _ = self.ssl_fingerprint + # we're disabling warnings here because these attributes are defined dynamically # through linode.objects.Base, and pylint isn't privy if os.path.isfile(os.path.expanduser(cert_file)): From fa7d8449e7394b28bf8b5eb16d30740ce266fbb7 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Fri, 16 Jul 2021 14:53:56 -0400 Subject: [PATCH 024/379] Address pylint concerns These weren't related to the PR (or revealed by my local version of pylint), but I suppose they should be addressed. The _filter_list function was totally unused and seemed like an old implementation of filtering, so I removed it entirely. --- linode_api4/linode_client.py | 19 ------------------- linode_api4/login_client.py | 2 +- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index e25a5f7cb..c9a1f7b6c 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1606,25 +1606,6 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): return v # helper functions - def _filter_list(self, results, **filter_by): - if not results or not len(results): - return results - - if not filter_by or not len(filter_by): - return results - - for key in filter_by.keys(): - if not key in vars(results[0]): - raise ValueError("Cannot filter {} by {}".format(type(results[0]), key)) - if isinstance(vars(results[0])[key], Base) and isinstance(filter_by[key], Base): - results = [ r for r in results if vars(r)[key].id == filter_by[key].id ] - elif isinstance(vars(results[0])[key], str) and isinstance(filter_by[key], str): - results = [ r for r in results if filter_by[key].lower() in vars(r)[key].lower() ] - else: - results = [ r for r in results if vars(r)[key] == filter_by[key] ] - - return results - def _get_and_filter(self, obj_type, *filters): parsed_filters = None if filters: diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index d9d219ca8..39fd1d604 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -266,7 +266,7 @@ def parse(scopes): # special all-scope case if scopes == '*': return [ getattr(OAuthScopes._scope_families[s], 'all') - for s in OAuthScopes._scope_families ] + for s in OAuthScopes._scope_families ] # pylint: disable=consider-using-dict-items for scope in scopes.split(','): resource = access = None From 5351e5cd101f406c54a517fd6dc1a7a9139a46d5 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Mon, 19 Jul 2021 14:58:22 -0400 Subject: [PATCH 025/379] Bumped version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b6f8e3c29..7ecc16054 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.2.0', + version='5.2.1', description='The official python SDK for Linode API v4', long_description=long_description, From b7ada3b53f4e8382777ac6b13fd5e608dfd8179e Mon Sep 17 00:00:00 2001 From: Michael Russell Date: Thu, 29 Jul 2021 17:01:26 -0700 Subject: [PATCH 026/379] Introduce A Few More Volume Sanity Checks - introduce few more baseline coverages of volume functionality - reduce network chatter, avoid making a call to detach if volume doesn't have a linode_id attached to it - tox.ini, update for localhost, helps speed being able to drop a debug breakpoint within a given test and work with tox/pytest - update client test to account for additional fixture - introduce additional volume fixture to represent attached volume from the start to better isolate detach functionality check Resolves: tests/volume-sanity-updates --- linode_api4/objects/volume.py | 2 ++ test/fixtures/volumes.json | 13 +++++++- test/linode_client_test.py | 5 ++- test/objects/volume_test.py | 58 +++++++++++++++++++++++++++++++++++ tox.ini | 10 ++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 1140e5e80..657aa63ec 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -37,6 +37,8 @@ def detach(self): """ Detaches this Volume if it is attached """ + if self.linode_id is None or self.linode_id == "": + return True self._client.post('{}/detach'.format(Volume.api_endpoint), model=self) return True diff --git a/test/fixtures/volumes.json b/test/fixtures/volumes.json index f93f252e0..52387a1b0 100644 --- a/test/fixtures/volumes.json +++ b/test/fixtures/volumes.json @@ -21,9 +21,20 @@ "updated": "2017-08-07T04:00:00", "status": "active", "tags": [] + }, + { + "id": 3, + "label": "block3", + "created": "2017-08-06T17:00:00", + "region": "ap-south-1a", + "linode_id": 1, + "size": 200, + "updated": "2017-08-07T04:00:00", + "status": "active", + "tags": ["attached"] } ], - "results": 2, + "results": 3, "pages": 1, "page": 1 } diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 55ff3b38f..b404b1a2d 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -108,14 +108,17 @@ def test_image_create(self): def test_get_volumes(self): v = self.client.volumes() - self.assertEqual(len(v), 2) + self.assertEqual(len(v), 3) self.assertEqual(v[0].label, 'block1') self.assertEqual(v[0].region.id, 'us-east-1a') self.assertEqual(v[1].label, 'block2') self.assertEqual(v[1].size, 100) + self.assertEqual(v[2].size, 200) + self.assertEqual(v[2].label, 'block3') assert v[0].tags == ["something"] assert v[1].tags == [] + assert v[2].tags == ["attached"] def test_get_tags(self): """ diff --git a/test/objects/volume_test.py b/test/objects/volume_test.py index a592b31db..f21dd4cd0 100644 --- a/test/objects/volume_test.py +++ b/test/objects/volume_test.py @@ -8,6 +8,7 @@ class VolumeTest(ClientBaseCase): """ Tests methods of the Volume class """ + def test_get_volume(self): """ Tests that a volume is loaded correctly by ID @@ -38,3 +39,60 @@ def test_update_volume_tags(self): assert m.call_url == '/volumes/{}'.format(volume.id) assert m.call_data['tags'] == ['test1', 'test2'] + + def test_clone_volume(self): + """ + Tests that cloning a volume returns new volume object with + same region and the given label + """ + volume_to_clone = self.client.volumes().first() + + with self.mock_post(f'volumes/{volume_to_clone.id}') as mock: + new_volume = volume_to_clone.clone('new-volume') + assert mock.call_url == f'/volumes/{volume_to_clone.id}/clone' + self.assertEqual(str(new_volume.region), str(volume_to_clone.region), 'the regions should be the same') + assert new_volume.id != str(volume_to_clone.id) + + def test_resize_volume(self): + """ + Tests that resizing a given volume volume works + """ + volume = self.client.volumes().first() + + with self.mock_post(f'volumes/{volume.id}') as mock: + volume.resize(3048) + assert mock.call_url == f'/volumes/{volume.id}/resize' + assert str(mock.call_data['size']) == '3048' + + def test_detach_volume(self): + """ + Tests that detaching the volume succeeds + """ + volume = self.client.volumes()[2] + + with self.mock_post(f'volumes/{volume.id}') as mock: + result = volume.detach() + assert mock.call_url == f'/volumes/{volume.id}/detach' + assert result is True + + def test_detach_volume_no_linode_id(self): + """ + Tests that a volume with no linode_id still detachs successfully + """ + volume = self.client.volumes().first() + + with self.mock_post(f'volumes/{volume.id}') as _mock: + result = volume.detach() + assert result is True + + def test_attach_volume_to_linode(self): + """ + Tests that the given volume attaches to the Linode via id + """ + volume = self.client.volumes().first() + + with self.mock_post(f'volumes/{volume.id}') as mock: + result = volume.attach(1) + assert mock.call_url == f'/volumes/{volume.id}/attach' + assert result is True + assert str(mock.call_data['linode_id']) == '1' \ No newline at end of file diff --git a/tox.ini b/tox.ini index e5a772295..be9546329 100644 --- a/tox.ini +++ b/tox.ini @@ -13,3 +13,13 @@ commands = coverage run --source linode_api4 -m pytest coverage report pylint linode_api4 + +[testenv:localhost] +deps = + pytest + mock + pylint +commands = + python setup.py install + pytest + pylint linode_api4 \ No newline at end of file From 9fd37a2f835836973cb2e90e9d1643a2bb542eef Mon Sep 17 00:00:00 2001 From: Michael Russell Date: Fri, 30 Jul 2021 09:00:50 -0700 Subject: [PATCH 027/379] Undo Volume Detach Behavior Edit, Test Edit, & Tox Edit - removed un-needed tox.ini edit - re-implemented default volume detach behavior - remove fluff sanity test Resolves: tests/volume-sanity-updates --- linode_api4/objects/volume.py | 2 -- test/objects/volume_test.py | 10 ---------- tox.ini | 10 ---------- 3 files changed, 22 deletions(-) diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 657aa63ec..1140e5e80 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -37,8 +37,6 @@ def detach(self): """ Detaches this Volume if it is attached """ - if self.linode_id is None or self.linode_id == "": - return True self._client.post('{}/detach'.format(Volume.api_endpoint), model=self) return True diff --git a/test/objects/volume_test.py b/test/objects/volume_test.py index f21dd4cd0..cc6bd4300 100644 --- a/test/objects/volume_test.py +++ b/test/objects/volume_test.py @@ -75,16 +75,6 @@ def test_detach_volume(self): assert mock.call_url == f'/volumes/{volume.id}/detach' assert result is True - def test_detach_volume_no_linode_id(self): - """ - Tests that a volume with no linode_id still detachs successfully - """ - volume = self.client.volumes().first() - - with self.mock_post(f'volumes/{volume.id}') as _mock: - result = volume.detach() - assert result is True - def test_attach_volume_to_linode(self): """ Tests that the given volume attaches to the Linode via id diff --git a/tox.ini b/tox.ini index be9546329..e5a772295 100644 --- a/tox.ini +++ b/tox.ini @@ -13,13 +13,3 @@ commands = coverage run --source linode_api4 -m pytest coverage report pylint linode_api4 - -[testenv:localhost] -deps = - pytest - mock - pylint -commands = - python setup.py install - pytest - pylint linode_api4 \ No newline at end of file From 78d0bc878edd9bbd10529ee8b092a5384248ca6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Fr=C3=B8hlich?= Date: Fri, 10 Sep 2021 14:00:14 +0200 Subject: [PATCH 028/379] Update linode_client.py Fix tiny typo --- linode_api4/linode_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index c9a1f7b6c..6baccaac7 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -231,7 +231,7 @@ def instance_create(self, ltype, region, image=None, the generated password. :rtype: Instance or tuple(Instance, str) :raises ApiError: If contacting the API fails - :raises UnexpectedResponseError: If the API resposne is somehow malformed. + :raises UnexpectedResponseError: If the API response is somehow malformed. This usually indicates that you are using an outdated library. """ From 2cdaa9fd195321008e7b6f753bf90e7a7451460c Mon Sep 17 00:00:00 2001 From: Will Smith Date: Fri, 25 Feb 2022 16:09:47 -0500 Subject: [PATCH 029/379] bug: Fix "Install on Linode" example project This was last updated in 2019, and since then the `OAuthScopes.Linodes.create` was removed in favor of the more modern `OAuthScopes.Linodes.read_write`. In addition to this, I fixed some formatting and added comments in `app.py` to hopefully make it more clear what the function of each piece is. :warning: This example application still does not work, as CloudFlare is currently blocking automated requests to the token expiration endpoint on login.linode.com; I will be trying to get this fixed, and am leaving the call to expire the token in place since it _should_ work as intended here. --- examples/install-on-linode/app.py | 46 +++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/examples/install-on-linode/app.py b/examples/install-on-linode/app.py index 29086f081..e203973e1 100644 --- a/examples/install-on-linode/app.py +++ b/examples/install-on-linode/app.py @@ -4,14 +4,26 @@ Type, OAuthScopes) import config +# define our flask app app=Flask(__name__) app.config['SECRET_KEY'] = config.secret_key + def get_login_client(): + """ + Returns a LinodeLoginClient configured as per the config module in this + example project. + """ return LinodeLoginClient(config.client_id, config.client_secret) + @app.route('/') def index(): + """ + This route renders the main page, where users land when visiting the example + site normally. This will present a simple form to deploy a Linode and allow + them to submit the forum. + """ client = LinodeClient('no-token') types = client.linode.types(Type.label.contains("Linode")) regions = client.regions() @@ -23,27 +35,49 @@ def index(): stackscript=stackscript ) + @app.route('/', methods=["POST"]) def start_auth(): + """ + This route is called when the forum rendered by GET / is submitted. This + will store the selections in the Flaks session before redirecting to + login.linode.com to log into configured OAuth Client. + """ login_client = get_login_client() session['dc'] = request.form['region'] session['distro'] = request.form['distribution'] session['type'] = request.form['type'] - return redirect(login_client.generate_login_url(scopes=OAuthScopes.Linodes.create)) + return redirect(login_client.generate_login_url(scopes=OAuthScopes.Linodes.read_write)) + @app.route('/auth_callback') def auth_callback(): + """ + This route is where users who log in to our OAuth Client will be redirected + from login.linode.com; it is responsible for completing the OAuth Workflow + using the Exchange Code provided by the login server, and then proceeding with + application logic. + """ + # complete the OAuth flow by exchanging the Exchange Code we were given + # with login.linode.com to get a working OAuth Token that we can use to + # make requests on the user's behalf. code = request.args.get('code') login_client = get_login_client() token, scopes, _, _ = login_client.finish_oauth(code) - # ensure we have sufficient scopes - if not OAuthScopes.Linodes.create in scopes: + # ensure we were granted sufficient scopes - this is a best practice, but + # at present users cannot elect to give us lower scopes than what we requested. + # In the future they may be allowed to grant partial access. + if not OAuthScopes.Linodes.read_write in scopes: return render_template('error.html', error='Insufficient scopes granted to deploy {}'\ .format(config.application_name)) + # application logic - create the linode (linode, password) = make_instance(token, session['type'], session['dc'], session['distro']) + # expire the OAuth Token we were given, effectively logging the user out of + # of our application. While this isn't strictly required, it's a good + # practice when the user is done (normally when clicking "log out") get_login_client().expire_token(token) return render_template('success.html', password=password, @@ -51,7 +85,11 @@ def auth_callback(): application_name=config.application_name ) + def make_instance(token, type_id, region_id, distribution_id): + """ + A helper function to create a Linode with the selected fields. + """ client = LinodeClient('{}'.format(token)) stackscript = StackScript(client, config.stackscript_id) (linode, password) = client.linode.instance_create(type_id, region_id, @@ -62,6 +100,8 @@ def make_instance(token, type_id, region_id, distribution_id): raise RuntimeError("it didn't work") return linode, password + +# This actually starts the application when app.py is run if __name__ == '__main__': app.debug=True app.run() From d4d398b9138a15ca20a827f4a3156163fbaebc41 Mon Sep 17 00:00:00 2001 From: William Smith Date: Mon, 18 Apr 2022 14:46:07 -0400 Subject: [PATCH 030/379] Create main.yml --- .github/workflows/main.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 000000000..edb094df9 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,25 @@ + +name: Test Suite + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.6','3.7','3.8','3.9','3.10'] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + run: | + pip install .[test] + tox From 2ebaa03cfc289be5614af4bed58cf9f2800973a1 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Mon, 18 Apr 2022 14:47:08 -0400 Subject: [PATCH 031/379] Remove travis config --- .travis.yml | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ebfe9eb20..000000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: python -python: - - "3.6" - - "3.7" - - "3.8" - - "3.9" -dist: xenial -install: - - python setup.py install - - pip install pylint pytest coverage -script: - - coverage run --source linode_api4 -m pytest - - coverage report - - pylint linode_api4 From f9dca3fdd8a78f634415b7513dee3bd488af66f6 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Mon, 18 Apr 2022 14:49:36 -0400 Subject: [PATCH 032/379] Tests require tox --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7ecc16054..f84471f1e 100755 --- a/setup.py +++ b/setup.py @@ -86,8 +86,8 @@ def get_test_suite(): extras_require={ }, - tests_require=[ + "tox", "mock", ], From 01fae285360646ce4bec4168fe3b3ccd582daaf6 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Mon, 18 Apr 2022 14:52:17 -0400 Subject: [PATCH 033/379] Do extras right --- setup.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/setup.py b/setup.py index f84471f1e..05ed6ea1a 100755 --- a/setup.py +++ b/setup.py @@ -85,11 +85,7 @@ def get_test_suite(): ], extras_require={ + "test": ["tox"], }, - tests_require=[ - "tox", - "mock", - ], - test_suite = 'setup.get_test_suite' ) From 75e85d963604ebc332c4397043a18154e19c7ce1 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Mon, 18 Apr 2022 14:56:36 -0400 Subject: [PATCH 034/379] Consider using f-string later --- .pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pylintrc b/.pylintrc index b272a27d5..9099eec78 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value +disable=blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option From 5544df45dce4956f0c1a3e3fb7b6c64359425902 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 19 Apr 2022 10:28:56 -0400 Subject: [PATCH 035/379] Test on python 3.11 too --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index edb094df9..95cec6813 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.6','3.7','3.8','3.9','3.10'] + python-version: ['3.6','3.7','3.8','3.9','3.10','3,11'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 From a5e04c7eb21a88ee57b485164a06b998f26d5582 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Tue, 19 Apr 2022 10:30:54 -0400 Subject: [PATCH 036/379] Actually don't --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 95cec6813..edb094df9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.6','3.7','3.8','3.9','3.10','3,11'] + python-version: ['3.6','3.7','3.8','3.9','3.10'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 From ba8e1f1dd1d1935f45ca79f785a2b7d0d3ea8e47 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Tue, 27 Sep 2022 12:32:56 -0400 Subject: [PATCH 037/379] Add support for MySQL clusters --- .python-version | 1 + linode_api4/linode_client.py | 107 +++++++++ linode_api4/objects/__init__.py | 1 + linode_api4/objects/database.py | 154 +++++++++++++ test/fixtures/databases_engines.json | 17 ++ test/fixtures/databases_instances.json | 36 +++ test/fixtures/databases_mysql_instances.json | 38 +++ ...databases_mysql_instances_123_backups.json | 13 ++ ...sql_instances_123_backups_456_restore.json | 1 + ...bases_mysql_instances_123_credentials.json | 4 + ...mysql_instances_123_credentials_reset.json | 1 + .../databases_mysql_instances_123_patch.json | 1 + .../databases_mysql_instances_123_ssl.json | 3 + test/fixtures/databases_types.json | 45 ++++ test/objects/database_test.py | 216 ++++++++++++++++++ 15 files changed, 638 insertions(+) create mode 100644 .python-version create mode 100644 linode_api4/objects/database.py create mode 100644 test/fixtures/databases_engines.json create mode 100644 test/fixtures/databases_instances.json create mode 100644 test/fixtures/databases_mysql_instances.json create mode 100644 test/fixtures/databases_mysql_instances_123_backups.json create mode 100644 test/fixtures/databases_mysql_instances_123_backups_456_restore.json create mode 100644 test/fixtures/databases_mysql_instances_123_credentials.json create mode 100644 test/fixtures/databases_mysql_instances_123_credentials_reset.json create mode 100644 test/fixtures/databases_mysql_instances_123_patch.json create mode 100644 test/fixtures/databases_mysql_instances_123_ssl.json create mode 100644 test/fixtures/databases_types.json create mode 100644 test/objects/database_test.py diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..d20cc2bf0 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8.10 diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 6baccaac7..57d84caf9 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1114,6 +1114,110 @@ def cancel(self): return True +class DatabaseGroup(Group): + """ + Encapsulates Linode Managed Databases related methods of the :any:`LinodeClient`. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.database.instances() # use the DatabaseGroup + + This group contains all features beneath the `/databases` group in the API v4. + """ + + def types(self, *filters): + """ + Returns a list of Linode Database-compatible Instance types. + These may be used to create Managed Databases, or simply + referenced to on their own. DatabaseTypes can be + filtered to return specific types, for example:: + + database_types = client.database.types(DatabaseType.deprecated == False) + + :param filters: Any number of filters to apply to the query. + + :returns: A list of types that match the query. + :rtype: PaginatedList of DatabaseType + """ + return self.client._get_and_filter(DatabaseType, *filters) + + def engines(self, *filters): + """ + Returns a list of Linode Managed Database Engines. + These may be used to create Managed Databases, or simply + referenced to on their own. Engines can be filtered to + return specific engines, for example:: + + mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql') + + :param filters: Any number of filters to apply to the query. + + :returns: A list of types that match the query. + :rtype: PaginatedList of DatabaseEngine + """ + return self.client._get_and_filter(DatabaseEngine, *filters) + + def instances(self, *filters): + """ + Returns a list of Managed Databases active on this account. + + :param filters: Any number of filters to apply to this query. + + :returns: A list of databases that matched the query. + :rtype: PaginatedList of Database + """ + return self.client._get_and_filter(Database, *filters) + + def mysql_instances(self, *filters): + """ + Returns a list of Managed MySQL Databases active on this account. + + :param filters: Any number of filters to apply to this query. + + :returns: A list of MySQL databases that matched the query. + :rtype: PaginatedList of MySQLDatabase + """ + return self.client._get_and_filter(MySQLDatabase, *filters) + + def mysql_create(self, label, region, engine, type, **kwargs): + """ + Creates an :any:`MySQLDatabase` on this account with + the given label, region, engine, and node type. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + region = client.regions().first() + node_type = client.database.types()[0] + engine = client.database.engines(DatabaseEngine.engine == 'mysql')[0] + + new_database = client.database.mysql_create( + "example-database", + region, + engine.id, + type.id + ) + """ + + params = { + 'label': label, + 'region': region, + 'engine': engine, + 'type': type, + } + params.update(kwargs) + + result = self.client.post('/databases/mysql/instances', data=params) + + if 'id' not in result: + raise UnexpectedResponseError('Unexpected response when creating MySQL Database', json=result) + + d = MySQLDatabase(self, result['id'], result) + return d + + class LinodeClient: def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_interval=None): """ @@ -1186,6 +1290,9 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, #: Access methods related to LKE - see :any:`LKEGroup` for more information. self.lke = LKEGroup(self) + #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. + self.database = DatabaseGroup(self) + @property def _user_agent(self): return '{}python-linode_api4/{} {}'.format( diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index bf30e1ec5..e50853fac 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -15,3 +15,4 @@ from .tag import Tag from .object_storage import ObjectStorageCluster, ObjectStorageKeys from .lke import * +from .database import * diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py new file mode 100644 index 000000000..0daf7034e --- /dev/null +++ b/linode_api4/objects/database.py @@ -0,0 +1,154 @@ +from linode_api4.objects import Base, Property, MappedObject, DerivedBase, UnexpectedResponseError + + +class DatabaseType(Base): + api_endpoint = '/databases/types/{id}' + + properties = { + 'deprecated': Property(filterable=True), + 'disk': Property(), + 'engines': Property(), + 'id': Property(identifier=True), + 'label': Property(), + 'memory': Property(), + 'vcpus': Property(), + } + + def _populate(self, json): + """ + Allows changing the name "class" in JSON to "type_class" in python + """ + super()._populate(json) + + if 'class' in json: + setattr(self, 'type_class', json['class']) + else: + setattr(self, 'type_class', None) + + +class DatabaseEngine(Base): + api_endpoint = '/databases/engines/{id}' + + properties = { + 'id': Property(identifier=True), + 'engine': Property(filterable=True), + 'version': Property(filterable=True), + } + + +class Database(Base): + """ + A generic Database instance. + """ + + api_endpoint = '/databases/instances/{id}' + + properties = { + 'id': Property(), + 'label': Property(), + 'allow_list': Property(), + 'cluster_size': Property(), + 'created': Property(), + 'encrypted': Property(), + 'engine': Property(), + 'hosts': Property(), + 'instance_uri': Property(), + 'region': Property(), + 'status': Property(), + 'type': Property(), + 'updated': Property(), + 'updates': Property(), + 'version': Property(), + } + + +class MySQLDatabaseBackup(DerivedBase): + api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}' + derived_url_path = 'backups' + parent_id_name = 'database_id' + + properties = { + 'created': Property(is_datetime=True), + 'id': Property(identifier=True), + 'label': Property(), + 'type': Property(), + } + + def restore(self): + """ + Restore a backup to a Managed MySQL Database on your Account. + """ + + return self._client.post('{}/restore'.format(MySQLDatabaseBackup.api_endpoint), model=self) + + +class MySQLDatabase(Base): + api_endpoint = '/databases/mysql/instances/{id}' + + properties = { + 'id': Property(identifier=True), + 'label': Property(mutable=True, filterable=True), + 'allow_list': Property(mutable=True), + 'backups': Property(derived_class=MySQLDatabaseBackup), + 'cluster_size': Property(), + 'created': Property(is_datetime=True), + 'encrypted': Property(), + 'engine': Property(filterable=True), + 'hosts': Property(), + 'port': Property(), + 'region': Property(filterable=True), + 'replication_type': Property(), + 'ssl_connection': Property(), + 'status': Property(volatile=True, filterable=True), + 'type': Property(filterable=True), + 'updated': Property(volatile=True, is_datetime=True), + 'updates': Property(mutable=True), + 'version': Property(filterable=True), + } + + @property + def credentials(self): + resp = self._client.get('{}/credentials'.format(MySQLDatabase.api_endpoint), model=self) + return MappedObject(**resp) + + @property + def ssl(self): + resp = self._client.get('{}/ssl'.format(MySQLDatabase.api_endpoint), model=self) + return MappedObject(**resp) + + def credentials_reset(self): + """ + Reset the root password for a Managed MySQL Database. + """ + + self.invalidate() + + return self._client.post('{}/credentials/reset'.format(MySQLDatabase.api_endpoint), model=self) + + def patch(self): + """ + Apply security patches and updates to the underlying operating system of the Managed MySQL Database. + """ + + self.invalidate() + + return self._client.post('{}/patch'.format(MySQLDatabase.api_endpoint), model=self) + + def backup_create(self, label, **kwargs): + """ + Creates a snapshot backup of a Managed MySQL Database. + """ + + params = { + 'label': label, + } + params.update(kwargs) + + result = self._client.post('{}/backups'.format(MySQLDatabase.api_endpoint), model=self, data=params) + self.invalidate() + + if 'id' not in result: + raise UnexpectedResponseError('Unexpected response when creating backup', json=result) + + b = MySQLDatabaseBackup(self._client, result['id'], self.id, result) + return b diff --git a/test/fixtures/databases_engines.json b/test/fixtures/databases_engines.json new file mode 100644 index 000000000..6418f93ab --- /dev/null +++ b/test/fixtures/databases_engines.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "engine": "mysql", + "id": "mysql/8.0.26", + "version": "8.0.26" + }, + { + "engine": "postgresql", + "id": "postgresql/10.14", + "version": "10.14" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/databases_instances.json b/test/fixtures/databases_instances.json new file mode 100644 index 000000000..3b3f4d602 --- /dev/null +++ b/test/fixtures/databases_instances.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "allow_list": [ + "203.0.113.1/32", + "192.0.1.0/24" + ], + "cluster_size": 3, + "created": "2022-01-01T00:01:01", + "encrypted": false, + "engine": "mysql", + "hosts": { + "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + }, + "id": 123, + "instance_uri": "/v4/databases/mysql/instances/123", + "label": "example-db", + "region": "us-east", + "status": "active", + "type": "g6-dedicated-2", + "updated": "2022-01-01T00:01:01", + "updates": { + "day_of_week": 1, + "duration": 3, + "frequency": "weekly", + "hour_of_day": 0, + "week_of_month": null + }, + "version": "8.0.26" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json new file mode 100644 index 000000000..2ea73ddc2 --- /dev/null +++ b/test/fixtures/databases_mysql_instances.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "allow_list": [ + "203.0.113.1/32", + "192.0.1.0/24" + ], + "cluster_size": 3, + "created": "2022-01-01T00:01:01", + "encrypted": false, + "engine": "mysql", + "hosts": { + "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + }, + "id": 123, + "label": "example-db", + "port": 3306, + "region": "us-east", + "replication_type": "semi_synch", + "ssl_connection": true, + "status": "active", + "type": "g6-dedicated-2", + "updated": "2022-01-01T00:01:01", + "updates": { + "day_of_week": 1, + "duration": 3, + "frequency": "weekly", + "hour_of_day": 0, + "week_of_month": null + }, + "version": "8.0.26" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_backups.json b/test/fixtures/databases_mysql_instances_123_backups.json new file mode 100644 index 000000000..671c68826 --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_backups.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "created": "2022-01-01T00:01:01", + "id": 456, + "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", + "type": "auto" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_backups_456_restore.json b/test/fixtures/databases_mysql_instances_123_backups_456_restore.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_backups_456_restore.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_credentials.json b/test/fixtures/databases_mysql_instances_123_credentials.json new file mode 100644 index 000000000..217c27c00 --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_credentials.json @@ -0,0 +1,4 @@ +{ + "password": "s3cur3P@ssw0rd", + "username": "linroot" +} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_credentials_reset.json b/test/fixtures/databases_mysql_instances_123_credentials_reset.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_credentials_reset.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_patch.json b/test/fixtures/databases_mysql_instances_123_patch.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_patch.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_ssl.json b/test/fixtures/databases_mysql_instances_123_ssl.json new file mode 100644 index 000000000..a331c5cd6 --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_ssl.json @@ -0,0 +1,3 @@ +{ + "ca_certificate": "LS0tLS1CRUdJ...==" +} \ No newline at end of file diff --git a/test/fixtures/databases_types.json b/test/fixtures/databases_types.json new file mode 100644 index 000000000..fec5c0234 --- /dev/null +++ b/test/fixtures/databases_types.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "class": "nanode", + "deprecated": false, + "disk": 25600, + "engines": { + "mongodb": [ + { + "price": { + "hourly": 0.03, + "monthly": 20 + }, + "quantity": 1 + } + ], + "mysql": [ + { + "price": { + "hourly": 0.03, + "monthly": 20 + }, + "quantity": 1 + } + ], + "postgresql": [ + { + "price": { + "hourly": 0.03, + "monthly": 20 + }, + "quantity": 1 + } + ] + }, + "id": "g6-nanode-1", + "label": "DBaaS - Nanode 1GB", + "memory": 1024, + "vcpus": 1 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/objects/database_test.py b/test/objects/database_test.py new file mode 100644 index 000000000..c3ff6ffdb --- /dev/null +++ b/test/objects/database_test.py @@ -0,0 +1,216 @@ +from test.base import ClientBaseCase + +from linode_api4.objects import MySQLDatabase + + +class DatabaseTest(ClientBaseCase): + """ + Tests methods of the DatabaseGroup class + """ + + def test_get_types(self): + """ + Test that database types are properly handled + """ + types = self.client.database.types() + + self.assertEqual(len(types), 1) + self.assertEqual(types[0].type_class, 'nanode') + self.assertEqual(types[0].id, 'g6-nanode-1') + self.assertEqual(types[0].engines.mongodb[0].price.monthly, 20) + + def test_get_engines(self): + """ + Test that database engines are properly handled + """ + engines = self.client.database.engines() + + self.assertEqual(len(engines), 2) + + self.assertEqual(engines[0].engine, 'mysql') + self.assertEqual(engines[0].id, 'mysql/8.0.26') + self.assertEqual(engines[0].version, '8.0.26') + + self.assertEqual(engines[1].engine, 'postgresql') + self.assertEqual(engines[1].id, 'postgresql/10.14') + self.assertEqual(engines[1].version, '10.14') + + def test_get_databases(self): + """ + Test that databases are properly handled + """ + dbs = self.client.database.instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, 'mysql') + self.assertEqual(dbs[0].hosts.primary, 'lin-123-456-mysql-mysql-primary.servers.linodedb.net') + self.assertEqual(dbs[0].hosts.secondary, 'lin-123-456-mysql-primary-private.servers.linodedb.net') + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, '8.0.26') + + +class MySQLDatabaseTest(ClientBaseCase): + """ + Tests methods of the MySQLDatabase class + """ + + def test_get_instances(self): + """ + Test that database types are properly handled + """ + dbs = self.client.database.mysql_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, 'mysql') + self.assertEqual(dbs[0].hosts.primary, 'lin-123-456-mysql-mysql-primary.servers.linodedb.net') + self.assertEqual(dbs[0].hosts.secondary, 'lin-123-456-mysql-primary-private.servers.linodedb.net') + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, '8.0.26') + + def test_create(self): + """ + Test that MySQL databases can be created + """ + + with self.mock_post('/databases/mysql/instances') as m: + # We don't care about errors here; we just want to + # validate the request. + try: + self.client.database.mysql_create( + 'cool', + 'us-southeast', + 'mysql/8.0.26', + 'g6-standard-1', + cluster_size=3 + ) + except Exception: + pass + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mysql/instances') + self.assertEqual(m.call_data['label'], 'cool') + self.assertEqual(m.call_data['region'], 'us-southeast') + self.assertEqual(m.call_data['engine'], 'mysql/8.0.26') + self.assertEqual(m.call_data['type'], 'g6-standard-1') + self.assertEqual(m.call_data['cluster_size'], 3) + + def test_update(self): + """ + Test that the MySQL database can be updated + """ + + with self.mock_put('/databases/mysql/instances/123') as m: + new_allow_list = ['192.168.0.1/32'] + + db = MySQLDatabase(self.client, 123) + + db.updates.day_of_week = 2 + db.allow_list = new_allow_list + db.label = 'cool' + + db.save() + + self.assertEqual(m.method, 'put') + self.assertEqual(m.call_url, '/databases/mysql/instances/123') + self.assertEqual(m.call_data['label'], 'cool') + self.assertEqual(m.call_data['updates']['day_of_week'], 2) + self.assertEqual(m.call_data['allow_list'], new_allow_list) + + def test_list_backups(self): + """ + Test that MySQL backups list properly + """ + + db = MySQLDatabase(self.client, 123) + backups = db.backups + + self.assertEqual(len(backups), 1) + + self.assertEqual(backups[0].id, 456) + self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI') + self.assertEqual(backups[0].type, 'auto') + + def test_create_backup(self): + """ + Test that MySQL database backups can be updated + """ + + with self.mock_post('/databases/mysql/instances/123/backups') as m: + db = MySQLDatabase(self.client, 123) + + # We don't care about errors here; we just want to + # validate the request. + try: + db.backup_create('mybackup', target='secondary') + except Exception: + pass + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups') + self.assertEqual(m.call_data['label'], 'mybackup') + self.assertEqual(m.call_data['target'], 'secondary') + + def test_backup_restore(self): + """ + Test that MySQL database backups can be restored + """ + + with self.mock_post('/databases/mysql/instances/123/backups/456/restore') as m: + db = MySQLDatabase(self.client, 123) + + db.backups[0].restore() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups/456/restore') + + def test_patch(self): + with self.mock_post('/databases/mysql/instances/123/patch') as m: + db = MySQLDatabase(self.client, 123) + + db.patch() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mysql/instances/123/patch') + + def test_get_ssl(self): + """ + Test MySQL SSL cert logic + """ + db = MySQLDatabase(self.client, 123) + + ssl = db.ssl + + self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==') + + def test_get_credentials(self): + """ + Test MySQL credentials logic + """ + db = MySQLDatabase(self.client, 123) + + creds = db.credentials + + self.assertEqual(creds.password, 's3cur3P@ssw0rd') + self.assertEqual(creds.username, 'linroot') + + def test_reset_credentials(self): + """ + Test resetting MySQL credentials + """ + with self.mock_post('/databases/mysql/instances/123/credentials/reset') as m: + db = MySQLDatabase(self.client, 123) + + db.credentials_reset() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mysql/instances/123/credentials/reset') From 6f3ae939a7eb7c6b3e8663b71370a0a19974ce13 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Tue, 27 Sep 2022 13:34:31 -0400 Subject: [PATCH 038/379] Add .instance to generic database object --- linode_api4/linode_client.py | 2 +- linode_api4/objects/database.py | 21 +++++++++++++++++++++ test/objects/database_test.py | 11 +++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 57d84caf9..d0e419e06 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1185,7 +1185,7 @@ def mysql_create(self, label, region, engine, type, **kwargs): Creates an :any:`MySQLDatabase` on this account with the given label, region, engine, and node type. For example:: - client = LinodeClient(TOKEN) + client = LinodeClient(TOKEN) # look up Region and Types to use. In this example I'm just using # the first ones returned. diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 0daf7034e..f270e6d70 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -61,6 +61,27 @@ class Database(Base): 'version': Property(), } + @property + def instance(self): + """ + Returns the underlying database object for the corresponding database + engine. This is useful for performing operations on generic databases. + + The following is an example of printing credentials for all databases regardless of engine:: + + client = LinodeClient(TOKEN) + + databases = client.database.instances() + + for db in databases: + print(f"{db.hosts.primary}: {db.instance.credentials.username} {db.instance.credentials.password}") + """ + + engine_type_translation = { + 'mysql': MySQLDatabase + } + + return engine_type_translation[self.engine](self._client, self.id) class MySQLDatabaseBackup(DerivedBase): api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}' diff --git a/test/objects/database_test.py b/test/objects/database_test.py index c3ff6ffdb..eedd3a41e 100644 --- a/test/objects/database_test.py +++ b/test/objects/database_test.py @@ -53,6 +53,17 @@ def test_get_databases(self): self.assertEqual(dbs[0].updates.duration, 3) self.assertEqual(dbs[0].version, '8.0.26') + def test_database_instance(self): + """ + Ensures that the .instance attribute properly translates database types + """ + + dbs = self.client.database.instances() + db_translated = dbs[0].instance + + self.assertTrue(isinstance(db_translated, MySQLDatabase)) + self.assertEqual(db_translated.ssl_connection, True) + class MySQLDatabaseTest(ClientBaseCase): """ From a59af7e4294e7de59c8baae6916b0904a792eaa0 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Tue, 27 Sep 2022 13:35:51 -0400 Subject: [PATCH 039/379] Add no engine case --- linode_api4/objects/database.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index f270e6d70..4e82c8b55 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -81,6 +81,9 @@ def instance(self): 'mysql': MySQLDatabase } + if self.engine not in engine_type_translation: + return None + return engine_type_translation[self.engine](self._client, self.id) class MySQLDatabaseBackup(DerivedBase): From 5acf4fc1abed7da5113ee36184e95e387f701411 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Tue, 27 Sep 2022 13:50:30 -0400 Subject: [PATCH 040/379] Cache property endpoints --- linode_api4/objects/database.py | 52 ++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 4e82c8b55..486fcb824 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -77,14 +77,29 @@ def instance(self): print(f"{db.hosts.primary}: {db.instance.credentials.username} {db.instance.credentials.password}") """ - engine_type_translation = { - 'mysql': MySQLDatabase - } + if not hasattr(self, '_instance'): + engine_type_translation = { + 'mysql': MySQLDatabase + } + + if self.engine not in engine_type_translation: + return None + + self._set('_instance', engine_type_translation[self.engine](self._client, self.id)) + + return self._instance + + def invalidate(self): + """ + Clear out cached properties. + """ + + for attr in ['_instance']: + if hasattr(self, attr): + delattr(self, attr) - if self.engine not in engine_type_translation: - return None + Base.invalidate(self) - return engine_type_translation[self.engine](self._client, self.id) class MySQLDatabaseBackup(DerivedBase): api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}' @@ -132,13 +147,19 @@ class MySQLDatabase(Base): @property def credentials(self): - resp = self._client.get('{}/credentials'.format(MySQLDatabase.api_endpoint), model=self) - return MappedObject(**resp) + if not hasattr(self, '_credentials'): + resp = self._client.get('{}/credentials'.format(MySQLDatabase.api_endpoint), model=self) + self._set('_credentials', MappedObject(**resp)) + + return self._credentials @property def ssl(self): - resp = self._client.get('{}/ssl'.format(MySQLDatabase.api_endpoint), model=self) - return MappedObject(**resp) + if not hasattr(self, '_ssl'): + resp = self._client.get('{}/ssl'.format(MySQLDatabase.api_endpoint), model=self) + self._set('_ssl', MappedObject(**resp)) + + return self._ssl def credentials_reset(self): """ @@ -176,3 +197,14 @@ def backup_create(self, label, **kwargs): b = MySQLDatabaseBackup(self._client, result['id'], self.id, result) return b + + def invalidate(self): + """ + Clear out cached properties. + """ + + for attr in ['_ssl', '_credentials']: + if hasattr(self, attr): + delattr(self, attr) + + Base.invalidate(self) From 42eed0b9dd4dde91a82be079947b9884d463fc5d Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 10:30:51 -0400 Subject: [PATCH 041/379] Add PostgreSQL support --- linode_api4/linode_client.py | 48 +++++ linode_api4/objects/database.py | 113 +++++++++++- .../databases_postgresql_instances.json | 39 ++++ ...ases_postgresql_instances_123_backups.json | 13 ++ ...sql_instances_123_backups_456_restore.json | 1 + ..._postgresql_instances_123_credentials.json | 4 + ...resql_instances_123_credentials_reset.json | 1 + ...abases_postgresql_instances_123_patch.json | 1 + ...atabases_postgresql_instances_123_ssl.json | 3 + test/objects/database_test.py | 169 ++++++++++++++++++ 10 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/databases_postgresql_instances.json create mode 100644 test/fixtures/databases_postgresql_instances_123_backups.json create mode 100644 test/fixtures/databases_postgresql_instances_123_backups_456_restore.json create mode 100644 test/fixtures/databases_postgresql_instances_123_credentials.json create mode 100644 test/fixtures/databases_postgresql_instances_123_credentials_reset.json create mode 100644 test/fixtures/databases_postgresql_instances_123_patch.json create mode 100644 test/fixtures/databases_postgresql_instances_123_ssl.json diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index d0e419e06..8b3b4e19c 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1217,6 +1217,54 @@ def mysql_create(self, label, region, engine, type, **kwargs): d = MySQLDatabase(self, result['id'], result) return d + def postgresql_instances(self, *filters): + """ + Returns a list of Managed PostgreSQL Databases active on this account. + + :param filters: Any number of filters to apply to this query. + + :returns: A list of PostgreSQL databases that matched the query. + :rtype: PaginatedList of PostgreSQLDatabase + """ + return self.client._get_and_filter(PostgreSQLDatabase, *filters) + + def postgresql_create(self, label, region, engine, type, **kwargs): + """ + Creates an :any:`PostgreSQLDatabase` on this account with + the given label, region, engine, and node type. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + region = client.regions().first() + node_type = client.database.types()[0] + engine = client.database.engines(DatabaseEngine.engine == 'postgresql')[0] + + new_database = client.database.postgresql_create( + "example-database", + region, + engine.id, + type.id + ) + """ + + params = { + 'label': label, + 'region': region, + 'engine': engine, + 'type': type, + } + params.update(kwargs) + + result = self.client.post('/databases/postgresql/instances', data=params) + + if 'id' not in result: + raise UnexpectedResponseError('Unexpected response when creating PostgreSQL Database', json=result) + + d = PostgreSQLDatabase(self, result['id'], result) + return d + class LinodeClient: def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_interval=None): diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 486fcb824..e7de69567 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -79,7 +79,8 @@ def instance(self): if not hasattr(self, '_instance'): engine_type_translation = { - 'mysql': MySQLDatabase + 'mysql': MySQLDatabase, + 'postgresql': PostgreSQLDatabase, } if self.engine not in engine_type_translation: @@ -208,3 +209,113 @@ def invalidate(self): delattr(self, attr) Base.invalidate(self) + + +class PostgreSQLDatabaseBackup(DerivedBase): + api_endpoint = '/databases/postgresql/instances/{database_id}/backups/{id}' + derived_url_path = 'backups' + parent_id_name = 'database_id' + + properties = { + 'created': Property(is_datetime=True), + 'id': Property(identifier=True), + 'label': Property(), + 'type': Property(), + } + + def restore(self): + """ + Restore a backup to a Managed PostgreSQL Database on your Account. + """ + + return self._client.post('{}/restore'.format(PostgreSQLDatabaseBackup.api_endpoint), model=self) + + +class PostgreSQLDatabase(Base): + api_endpoint = '/databases/postgresql/instances/{id}' + + properties = { + 'id': Property(identifier=True), + 'label': Property(mutable=True, filterable=True), + 'allow_list': Property(mutable=True), + 'backups': Property(derived_class=PostgreSQLDatabaseBackup), + 'cluster_size': Property(), + 'created': Property(is_datetime=True), + 'encrypted': Property(), + 'engine': Property(filterable=True), + 'hosts': Property(), + 'port': Property(), + 'region': Property(filterable=True), + 'replication_commit_type': Property(), + 'replication_type': Property(), + 'ssl_connection': Property(), + 'status': Property(volatile=True, filterable=True), + 'type': Property(filterable=True), + 'updated': Property(volatile=True, is_datetime=True), + 'updates': Property(mutable=True), + 'version': Property(filterable=True), + } + + @property + def credentials(self): + if not hasattr(self, '_credentials'): + resp = self._client.get('{}/credentials'.format(PostgreSQLDatabase.api_endpoint), model=self) + self._set('_credentials', MappedObject(**resp)) + + return self._credentials + + @property + def ssl(self): + if not hasattr(self, '_ssl'): + resp = self._client.get('{}/ssl'.format(PostgreSQLDatabase.api_endpoint), model=self) + self._set('_ssl', MappedObject(**resp)) + + return self._ssl + + def credentials_reset(self): + """ + Reset the root password for a Managed PostgreSQL Database. + """ + + self.invalidate() + + return self._client.post('{}/credentials/reset'.format(PostgreSQLDatabase.api_endpoint), model=self) + + def patch(self): + """ + Apply security patches and updates to the underlying operating system of the Managed PostgreSQL Database. + """ + + self.invalidate() + + return self._client.post('{}/patch'.format(PostgreSQLDatabase.api_endpoint), model=self) + + def backup_create(self, label, **kwargs): + """ + Creates a snapshot backup of a Managed PostgreSQL Database. + """ + + params = { + 'label': label, + } + params.update(kwargs) + + result = self._client.post('{}/backups'.format(PostgreSQLDatabase.api_endpoint), model=self, data=params) + self.invalidate() + + if 'id' not in result: + raise UnexpectedResponseError('Unexpected response when creating backup', json=result) + + b = MySQLDatabaseBackup(self._client, result['id'], self.id, result) + return b + + def invalidate(self): + """ + Clear out cached properties. + """ + + for attr in ['_ssl', '_credentials']: + if hasattr(self, attr): + delattr(self, attr) + + Base.invalidate(self) diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json new file mode 100644 index 000000000..2740b836d --- /dev/null +++ b/test/fixtures/databases_postgresql_instances.json @@ -0,0 +1,39 @@ +{ + "data": [ + { + "allow_list": [ + "203.0.113.1/32", + "192.0.1.0/24" + ], + "cluster_size": 3, + "created": "2022-01-01T00:01:01", + "encrypted": false, + "engine": "postgresql", + "hosts": { + "primary": "lin-0000-000-pgsql-primary.servers.linodedb.net", + "secondary": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" + }, + "id": 123, + "label": "example-db", + "port": 3306, + "region": "us-east", + "replication_commit_type": "local", + "replication_type": "semi_synch", + "ssl_connection": true, + "status": "active", + "type": "g6-dedicated-2", + "updated": "2022-01-01T00:01:01", + "updates": { + "day_of_week": 1, + "duration": 3, + "frequency": "weekly", + "hour_of_day": 0, + "week_of_month": null + }, + "version": "13.2" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_backups.json b/test/fixtures/databases_postgresql_instances_123_backups.json new file mode 100644 index 000000000..671c68826 --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_backups.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "created": "2022-01-01T00:01:01", + "id": 456, + "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", + "type": "auto" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json b/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_credentials.json b/test/fixtures/databases_postgresql_instances_123_credentials.json new file mode 100644 index 000000000..217c27c00 --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_credentials.json @@ -0,0 +1,4 @@ +{ + "password": "s3cur3P@ssw0rd", + "username": "linroot" +} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_credentials_reset.json b/test/fixtures/databases_postgresql_instances_123_credentials_reset.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_credentials_reset.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_patch.json b/test/fixtures/databases_postgresql_instances_123_patch.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_patch.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_ssl.json b/test/fixtures/databases_postgresql_instances_123_ssl.json new file mode 100644 index 000000000..a331c5cd6 --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_ssl.json @@ -0,0 +1,3 @@ +{ + "ca_certificate": "LS0tLS1CRUdJ...==" +} \ No newline at end of file diff --git a/test/objects/database_test.py b/test/objects/database_test.py index eedd3a41e..5202fd51e 100644 --- a/test/objects/database_test.py +++ b/test/objects/database_test.py @@ -1,3 +1,4 @@ +from linode_api4 import PostgreSQLDatabase from test.base import ClientBaseCase from linode_api4.objects import MySQLDatabase @@ -185,6 +186,9 @@ def test_backup_restore(self): self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups/456/restore') def test_patch(self): + """ + Test MySQL Database patching logic. + """ with self.mock_post('/databases/mysql/instances/123/patch') as m: db = MySQLDatabase(self.client, 123) @@ -225,3 +229,168 @@ def test_reset_credentials(self): self.assertEqual(m.method, 'post') self.assertEqual(m.call_url, '/databases/mysql/instances/123/credentials/reset') + + +class PostgreSQLDatabaseTest(ClientBaseCase): + """ + Tests methods of the PostgreSQLDatabase class + """ + + def test_get_instances(self): + """ + Test that database types are properly handled + """ + dbs = self.client.database.postgresql_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, 'postgresql') + self.assertEqual(dbs[0].hosts.primary, 'lin-0000-000-pgsql-primary.servers.linodedb.net') + self.assertEqual(dbs[0].hosts.secondary, 'lin-0000-000-pgsql-primary-private.servers.linodedb.net') + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, '13.2') + + def test_create(self): + """ + Test that PostgreSQL databases can be created + """ + + with self.mock_post('/databases/postgresql/instances') as m: + # We don't care about errors here; we just want to + # validate the request. + try: + self.client.database.postgresql_create( + 'cool', + 'us-southeast', + 'mysql/8.0.26', + 'g6-standard-1', + cluster_size=3 + ) + except Exception: + pass + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/postgresql/instances') + self.assertEqual(m.call_data['label'], 'cool') + self.assertEqual(m.call_data['region'], 'us-southeast') + self.assertEqual(m.call_data['engine'], 'mysql/8.0.26') + self.assertEqual(m.call_data['type'], 'g6-standard-1') + self.assertEqual(m.call_data['cluster_size'], 3) + + def test_update(self): + """ + Test that the PostgreSQL database can be updated + """ + + with self.mock_put('/databases/postgresql/instances/123') as m: + new_allow_list = ['192.168.0.1/32'] + + db = PostgreSQLDatabase(self.client, 123) + + db.updates.day_of_week = 2 + db.allow_list = new_allow_list + db.label = 'cool' + + db.save() + + self.assertEqual(m.method, 'put') + self.assertEqual(m.call_url, '/databases/postgresql/instances/123') + self.assertEqual(m.call_data['label'], 'cool') + self.assertEqual(m.call_data['updates']['day_of_week'], 2) + self.assertEqual(m.call_data['allow_list'], new_allow_list) + + def test_list_backups(self): + """ + Test that PostgreSQL backups list properly + """ + + db = PostgreSQLDatabase(self.client, 123) + backups = db.backups + + self.assertEqual(len(backups), 1) + + self.assertEqual(backups[0].id, 456) + self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI') + self.assertEqual(backups[0].type, 'auto') + + def test_create_backup(self): + """ + Test that PostgreSQL database backups can be created + """ + + with self.mock_post('/databases/postgresql/instances/123/backups') as m: + db = PostgreSQLDatabase(self.client, 123) + + # We don't care about errors here; we just want to + # validate the request. + try: + db.backup_create('mybackup', target='secondary') + except Exception: + pass + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/postgresql/instances/123/backups') + self.assertEqual(m.call_data['label'], 'mybackup') + self.assertEqual(m.call_data['target'], 'secondary') + + def test_backup_restore(self): + """ + Test that PostgreSQL database backups can be restored + """ + + with self.mock_post('/databases/postgresql/instances/123/backups/456/restore') as m: + db = PostgreSQLDatabase(self.client, 123) + + db.backups[0].restore() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/postgresql/instances/123/backups/456/restore') + + def test_patch(self): + """ + Test PostgreSQL Database patching logic. + """ + with self.mock_post('/databases/postgresql/instances/123/patch') as m: + db = PostgreSQLDatabase(self.client, 123) + + db.patch() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/postgresql/instances/123/patch') + + def test_get_ssl(self): + """ + Test PostgreSQL SSL cert logic + """ + db = PostgreSQLDatabase(self.client, 123) + + ssl = db.ssl + + self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==') + + def test_get_credentials(self): + """ + Test PostgreSQL credentials logic + """ + db = PostgreSQLDatabase(self.client, 123) + + creds = db.credentials + + self.assertEqual(creds.password, 's3cur3P@ssw0rd') + self.assertEqual(creds.username, 'linroot') + + def test_reset_credentials(self): + """ + Test resetting PostgreSQL credentials + """ + with self.mock_post('/databases/postgresql/instances/123/credentials/reset') as m: + db = PostgreSQLDatabase(self.client, 123) + + db.credentials_reset() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/postgresql/instances/123/credentials/reset') \ No newline at end of file From c7a0b1553b0f07d14b352b8ce42e2d315261b261 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 11:07:27 -0400 Subject: [PATCH 042/379] Add MongoDB support --- linode_api4/linode_client.py | 48 +++++ linode_api4/objects/database.py | 114 +++++++++++- .../fixtures/databases_mongodb_instances.json | 45 +++++ ...tabases_mongodb_instances_123_backups.json | 13 ++ ...odb_instances_123_backups_456_restore.json | 1 + ...ses_mongodb_instances_123_credentials.json | 4 + ...ngodb_instances_123_credentials_reset.json | 1 + ...databases_mongodb_instances_123_patch.json | 1 + .../databases_mongodb_instances_123_ssl.json | 3 + test/fixtures/mongodb.json | 3 + test/objects/database_test.py | 175 +++++++++++++++++- 11 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/databases_mongodb_instances.json create mode 100644 test/fixtures/databases_mongodb_instances_123_backups.json create mode 100644 test/fixtures/databases_mongodb_instances_123_backups_456_restore.json create mode 100644 test/fixtures/databases_mongodb_instances_123_credentials.json create mode 100644 test/fixtures/databases_mongodb_instances_123_credentials_reset.json create mode 100644 test/fixtures/databases_mongodb_instances_123_patch.json create mode 100644 test/fixtures/databases_mongodb_instances_123_ssl.json create mode 100644 test/fixtures/mongodb.json diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 8b3b4e19c..74a781a91 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1265,6 +1265,54 @@ def postgresql_create(self, label, region, engine, type, **kwargs): d = PostgreSQLDatabase(self, result['id'], result) return d + def mongodb_instances(self, *filters): + """ + Returns a list of Managed MongoDB Databases active on this account. + + :param filters: Any number of filters to apply to this query. + + :returns: A list of MongoDB databases that matched the query. + :rtype: PaginatedList of MongoDBDatabase + """ + return self.client._get_and_filter(MongoDBDatabase, *filters) + + def mongodb_create(self, label, region, engine, type, **kwargs): + """ + Creates an :any:`MongoDBDatabase` on this account with + the given label, region, engine, and node type. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + region = client.regions().first() + node_type = client.database.types()[0] + engine = client.database.engines(DatabaseEngine.engine == 'mongodb')[0] + + new_database = client.database.mongodb_create( + "example-database", + region, + engine.id, + type.id + ) + """ + + params = { + 'label': label, + 'region': region, + 'engine': engine, + 'type': type, + } + params.update(kwargs) + + result = self.client.post('/databases/mongodb/instances', data=params) + + if 'id' not in result: + raise UnexpectedResponseError('Unexpected response when creating MongoDB Database', json=result) + + d = MongoDBDatabase(self, result['id'], result) + return d + class LinodeClient: def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_interval=None): diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index e7de69567..6f15bd764 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -306,7 +306,119 @@ def backup_create(self, label, **kwargs): if 'id' not in result: raise UnexpectedResponseError('Unexpected response when creating backup', json=result) - b = MySQLDatabaseBackup(self._client, result['id'], self.id, result) + b = PostgreSQLDatabaseBackup(self._client, result['id'], self.id, result) + return b + + def invalidate(self): + """ + Clear out cached properties. + """ + + for attr in ['_ssl', '_credentials']: + if hasattr(self, attr): + delattr(self, attr) + + Base.invalidate(self) + + +class MongoDBDatabaseBackup(DerivedBase): + api_endpoint = '/databases/mongodb/instances/{database_id}/backups/{id}' + derived_url_path = 'backups' + parent_id_name = 'database_id' + + properties = { + 'created': Property(is_datetime=True), + 'id': Property(identifier=True), + 'label': Property(), + 'type': Property(), + } + + def restore(self): + """ + Restore a backup to a Managed MongoDB Database on your Account. + """ + + return self._client.post('{}/restore'.format(MongoDBDatabaseBackup.api_endpoint), model=self) + + +class MongoDBDatabase(Base): + api_endpoint = '/databases/mongodb/instances/{id}' + + properties = { + 'id': Property(identifier=True), + 'label': Property(mutable=True, filterable=True), + 'allow_list': Property(mutable=True), + 'backups': Property(derived_class=MongoDBDatabaseBackup), + 'cluster_size': Property(), + 'compression_type': Property(), + 'created': Property(is_datetime=True), + 'encrypted': Property(), + 'engine': Property(filterable=True), + 'hosts': Property(), + 'peers': Property(), + 'port': Property(), + 'region': Property(filterable=True), + 'replica_set': Property(), + 'ssl_connection': Property(), + 'status': Property(volatile=True, filterable=True), + 'storage_engine': Property(), + 'type': Property(filterable=True), + 'updated': Property(volatile=True, is_datetime=True), + 'updates': Property(mutable=True), + 'version': Property(filterable=True), + } + + @property + def credentials(self): + if not hasattr(self, '_credentials'): + resp = self._client.get('{}/credentials'.format(MongoDBDatabase.api_endpoint), model=self) + self._set('_credentials', MappedObject(**resp)) + + return self._credentials + + @property + def ssl(self): + if not hasattr(self, '_ssl'): + resp = self._client.get('{}/ssl'.format(MongoDBDatabase.api_endpoint), model=self) + self._set('_ssl', MappedObject(**resp)) + + return self._ssl + + def credentials_reset(self): + """ + Reset the root password for a Managed MongoDB Database. + """ + + self.invalidate() + + return self._client.post('{}/credentials/reset'.format(MongoDBDatabase.api_endpoint), model=self) + + def patch(self): + """ + Apply security patches and updates to the underlying operating system of the Managed MongoDB Database. + """ + + self.invalidate() + + return self._client.post('{}/patch'.format(MongoDBDatabase.api_endpoint), model=self) + + def backup_create(self, label, **kwargs): + """ + Creates a snapshot backup of a Managed MongoDB Database. + """ + + params = { + 'label': label, + } + params.update(kwargs) + + result = self._client.post('{}/backups'.format(MongoDBDatabase.api_endpoint), model=self, data=params) + self.invalidate() + + if 'id' not in result: + raise UnexpectedResponseError('Unexpected response when creating backup', json=result) + + b = MongoDBDatabaseBackup(self._client, result['id'], self.id, result) return b def invalidate(self): diff --git a/test/fixtures/databases_mongodb_instances.json b/test/fixtures/databases_mongodb_instances.json new file mode 100644 index 000000000..383a1b295 --- /dev/null +++ b/test/fixtures/databases_mongodb_instances.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "allow_list": [ + "203.0.113.1/32", + "192.0.1.0/24" + ], + "cluster_size": 3, + "compression_type": "none", + "created": "2022-01-01T00:01:01", + "encrypted": false, + "engine": "mongodb", + "hosts": { + "primary": "lin-0000-0000.servers.linodedb.net", + "secondary": null + }, + "id": 123, + "label": "example-db", + "peers": [ + "lin-0000-0000.servers.linodedb.net", + "lin-0000-0001.servers.linodedb.net", + "lin-0000-0002.servers.linodedb.net" + ], + "port": 27017, + "region": "us-east", + "replica_set": null, + "ssl_connection": true, + "status": "active", + "storage_engine": "wiredtiger", + "type": "g6-dedicated-2", + "updated": "2022-01-01T00:01:01", + "updates": { + "day_of_week": 1, + "duration": 3, + "frequency": "weekly", + "hour_of_day": 0, + "week_of_month": null + }, + "version": "4.4.10" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_backups.json b/test/fixtures/databases_mongodb_instances_123_backups.json new file mode 100644 index 000000000..671c68826 --- /dev/null +++ b/test/fixtures/databases_mongodb_instances_123_backups.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "created": "2022-01-01T00:01:01", + "id": 456, + "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", + "type": "auto" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_backups_456_restore.json b/test/fixtures/databases_mongodb_instances_123_backups_456_restore.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mongodb_instances_123_backups_456_restore.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_credentials.json b/test/fixtures/databases_mongodb_instances_123_credentials.json new file mode 100644 index 000000000..217c27c00 --- /dev/null +++ b/test/fixtures/databases_mongodb_instances_123_credentials.json @@ -0,0 +1,4 @@ +{ + "password": "s3cur3P@ssw0rd", + "username": "linroot" +} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_credentials_reset.json b/test/fixtures/databases_mongodb_instances_123_credentials_reset.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mongodb_instances_123_credentials_reset.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_patch.json b/test/fixtures/databases_mongodb_instances_123_patch.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mongodb_instances_123_patch.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_ssl.json b/test/fixtures/databases_mongodb_instances_123_ssl.json new file mode 100644 index 000000000..a331c5cd6 --- /dev/null +++ b/test/fixtures/databases_mongodb_instances_123_ssl.json @@ -0,0 +1,3 @@ +{ + "ca_certificate": "LS0tLS1CRUdJ...==" +} \ No newline at end of file diff --git a/test/fixtures/mongodb.json b/test/fixtures/mongodb.json new file mode 100644 index 000000000..a331c5cd6 --- /dev/null +++ b/test/fixtures/mongodb.json @@ -0,0 +1,3 @@ +{ + "ca_certificate": "LS0tLS1CRUdJ...==" +} \ No newline at end of file diff --git a/test/objects/database_test.py b/test/objects/database_test.py index 5202fd51e..8aed8e182 100644 --- a/test/objects/database_test.py +++ b/test/objects/database_test.py @@ -1,4 +1,4 @@ -from linode_api4 import PostgreSQLDatabase +from linode_api4 import PostgreSQLDatabase, MongoDBDatabase from test.base import ClientBaseCase from linode_api4.objects import MySQLDatabase @@ -266,7 +266,7 @@ def test_create(self): self.client.database.postgresql_create( 'cool', 'us-southeast', - 'mysql/8.0.26', + 'postgresql/13.2', 'g6-standard-1', cluster_size=3 ) @@ -277,7 +277,7 @@ def test_create(self): self.assertEqual(m.call_url, '/databases/postgresql/instances') self.assertEqual(m.call_data['label'], 'cool') self.assertEqual(m.call_data['region'], 'us-southeast') - self.assertEqual(m.call_data['engine'], 'mysql/8.0.26') + self.assertEqual(m.call_data['engine'], 'postgresql/13.2') self.assertEqual(m.call_data['type'], 'g6-standard-1') self.assertEqual(m.call_data['cluster_size'], 3) @@ -393,4 +393,171 @@ def test_reset_credentials(self): db.credentials_reset() self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/postgresql/instances/123/credentials/reset') \ No newline at end of file + self.assertEqual(m.call_url, '/databases/postgresql/instances/123/credentials/reset') + + +class MongoDBDatabaseTest(ClientBaseCase): + """ + Tests methods of the MongoDBDatabase class + """ + + def test_get_instances(self): + """ + Test that database types are properly handled + """ + dbs = self.client.database.mongodb_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].compression_type, 'none') + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, 'mongodb') + self.assertEqual(dbs[0].hosts.primary, 'lin-0000-0000.servers.linodedb.net') + self.assertEqual(dbs[0].hosts.secondary, None) + self.assertEqual(len(dbs[0].peers), 3) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, '4.4.10') + + def test_create(self): + """ + Test that MongoDB databases can be created + """ + + with self.mock_post('/databases/mongodb/instances') as m: + # We don't care about errors here; we just want to + # validate the request. + try: + self.client.database.mongodb_create( + 'cool', + 'us-southeast', + 'mongodb/4.4.10', + 'g6-standard-1', + cluster_size=3 + ) + except Exception: + pass + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mongodb/instances') + self.assertEqual(m.call_data['label'], 'cool') + self.assertEqual(m.call_data['region'], 'us-southeast') + self.assertEqual(m.call_data['engine'], 'mongodb/4.4.10') + self.assertEqual(m.call_data['type'], 'g6-standard-1') + self.assertEqual(m.call_data['cluster_size'], 3) + + def test_update(self): + """ + Test that the MongoDB database can be updated + """ + + with self.mock_put('/databases/mongodb/instances/123') as m: + new_allow_list = ['192.168.0.1/32'] + + db = MongoDBDatabase(self.client, 123) + + db.updates.day_of_week = 2 + db.allow_list = new_allow_list + db.label = 'cool' + + db.save() + + self.assertEqual(m.method, 'put') + self.assertEqual(m.call_url, '/databases/mongodb/instances/123') + self.assertEqual(m.call_data['label'], 'cool') + self.assertEqual(m.call_data['updates']['day_of_week'], 2) + self.assertEqual(m.call_data['allow_list'], new_allow_list) + + def test_list_backups(self): + """ + Test that MongoDB backups list properly + """ + + db = MongoDBDatabase(self.client, 123) + backups = db.backups + + self.assertEqual(len(backups), 1) + + self.assertEqual(backups[0].id, 456) + self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI') + self.assertEqual(backups[0].type, 'auto') + + def test_create_backup(self): + """ + Test that MongoDB database backups can be created + """ + + with self.mock_post('/databases/mongodb/instances/123/backups') as m: + db = MongoDBDatabase(self.client, 123) + + # We don't care about errors here; we just want to + # validate the request. + try: + db.backup_create('mybackup', target='secondary') + except Exception: + pass + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mongodb/instances/123/backups') + self.assertEqual(m.call_data['label'], 'mybackup') + self.assertEqual(m.call_data['target'], 'secondary') + + def test_backup_restore(self): + """ + Test that MongoDB database backups can be restored + """ + + with self.mock_post('/databases/mongodb/instances/123/backups/456/restore') as m: + db = MongoDBDatabase(self.client, 123) + + db.backups[0].restore() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mongodb/instances/123/backups/456/restore') + + def test_patch(self): + """ + Test MongoDB Database patching logic. + """ + with self.mock_post('/databases/mongodb/instances/123/patch') as m: + db = MongoDBDatabase(self.client, 123) + + db.patch() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mongodb/instances/123/patch') + + def test_get_ssl(self): + """ + Test MongoDB SSL cert logic + """ + db = MongoDBDatabase(self.client, 123) + + ssl = db.ssl + + self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==') + + def test_get_credentials(self): + """ + Test MongoDB credentials logic + """ + db = MongoDBDatabase(self.client, 123) + + creds = db.credentials + + self.assertEqual(creds.password, 's3cur3P@ssw0rd') + self.assertEqual(creds.username, 'linroot') + + def test_reset_credentials(self): + """ + Test resetting MongoDB credentials + """ + with self.mock_post('/databases/mongodb/instances/123/credentials/reset') as m: + db = MongoDBDatabase(self.client, 123) + + db.credentials_reset() + + self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, '/databases/mongodb/instances/123/credentials/reset') \ No newline at end of file From 9878a1edd42a11f356b3a347a15c8c000801c63d Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 11:17:58 -0400 Subject: [PATCH 043/379] Add Database group to docs --- docs/linode_api4/objects/models.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 2a3b704ce..089651613 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -14,6 +14,15 @@ Account Models :undoc-members: :inherited-members: +Database Models +------------- + +.. automodule:: linode_api4.objects.database + :members: + :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name + :undoc-members: + :inherited-members: + Domain Models ------------- From c9223cadbff9c5f04074b62b72481ea3c1391b45 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 11:20:49 -0400 Subject: [PATCH 044/379] Make linter happy --- .pylintrc | 2 +- linode_api4/objects/database.py | 60 +++++++++------------------------ 2 files changed, 17 insertions(+), 45 deletions(-) diff --git a/.pylintrc b/.pylintrc index 9099eec78..246360d05 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding +disable=blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 6f15bd764..eb75ff38a 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -102,8 +102,8 @@ def invalidate(self): Base.invalidate(self) -class MySQLDatabaseBackup(DerivedBase): - api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}' +class DatabaseBackup(DerivedBase): + api_endpoint = '' derived_url_path = 'backups' parent_id_name = 'database_id' @@ -116,10 +116,22 @@ class MySQLDatabaseBackup(DerivedBase): def restore(self): """ - Restore a backup to a Managed MySQL Database on your Account. + Restore a backup to a Managed Database on your Account. """ - return self._client.post('{}/restore'.format(MySQLDatabaseBackup.api_endpoint), model=self) + return self._client.post('{}/restore'.format(self.api_endpoint), model=self) + + +class MySQLDatabaseBackup(DatabaseBackup): + api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}' + + +class MongoDBDatabaseBackup(DatabaseBackup): + api_endpoint = '/databases/mongodb/instances/{database_id}/backups/{id}' + + +class PostgreSQLDatabaseBackup(DatabaseBackup): + api_endpoint = '/databases/postgresql/instances/{database_id}/backups/{id}' class MySQLDatabase(Base): @@ -211,26 +223,6 @@ def invalidate(self): Base.invalidate(self) -class PostgreSQLDatabaseBackup(DerivedBase): - api_endpoint = '/databases/postgresql/instances/{database_id}/backups/{id}' - derived_url_path = 'backups' - parent_id_name = 'database_id' - - properties = { - 'created': Property(is_datetime=True), - 'id': Property(identifier=True), - 'label': Property(), - 'type': Property(), - } - - def restore(self): - """ - Restore a backup to a Managed PostgreSQL Database on your Account. - """ - - return self._client.post('{}/restore'.format(PostgreSQLDatabaseBackup.api_endpoint), model=self) - - class PostgreSQLDatabase(Base): api_endpoint = '/databases/postgresql/instances/{id}' @@ -321,26 +313,6 @@ def invalidate(self): Base.invalidate(self) -class MongoDBDatabaseBackup(DerivedBase): - api_endpoint = '/databases/mongodb/instances/{database_id}/backups/{id}' - derived_url_path = 'backups' - parent_id_name = 'database_id' - - properties = { - 'created': Property(is_datetime=True), - 'id': Property(identifier=True), - 'label': Property(), - 'type': Property(), - } - - def restore(self): - """ - Restore a backup to a Managed MongoDB Database on your Account. - """ - - return self._client.post('{}/restore'.format(MongoDBDatabaseBackup.api_endpoint), model=self) - - class MongoDBDatabase(Base): api_endpoint = '/databases/mongodb/instances/{id}' From f77e0ad419d22dfb65b9e5edf5d7697f4bf2a8f1 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 12:13:55 -0400 Subject: [PATCH 045/379] Fix creation arg --- linode_api4/linode_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 74a781a91..434d622d8 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1214,7 +1214,7 @@ def mysql_create(self, label, region, engine, type, **kwargs): if 'id' not in result: raise UnexpectedResponseError('Unexpected response when creating MySQL Database', json=result) - d = MySQLDatabase(self, result['id'], result) + d = MySQLDatabase(self.client, result['id'], result) return d def postgresql_instances(self, *filters): @@ -1262,7 +1262,7 @@ def postgresql_create(self, label, region, engine, type, **kwargs): if 'id' not in result: raise UnexpectedResponseError('Unexpected response when creating PostgreSQL Database', json=result) - d = PostgreSQLDatabase(self, result['id'], result) + d = PostgreSQLDatabase(self.client, result['id'], result) return d def mongodb_instances(self, *filters): @@ -1310,7 +1310,7 @@ def mongodb_create(self, label, region, engine, type, **kwargs): if 'id' not in result: raise UnexpectedResponseError('Unexpected response when creating MongoDB Database', json=result) - d = MongoDBDatabase(self, result['id'], result) + d = MongoDBDatabase(self.client, result['id'], result) return d From d595ee389647d94dcfa86997bf8e60c2196da5c7 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 28 Sep 2022 14:56:04 -0400 Subject: [PATCH 046/379] Add label param comment Co-authored-by: Will Smith --- linode_api4/linode_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 434d622d8..e9ee8a3e5 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1199,6 +1199,14 @@ def mysql_create(self, label, region, engine, type, **kwargs): engine.id, type.id ) + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str or Region + :param engine: The engine to deploy this cluster with + :type engine: str or Engine + :param type: The Linode Type to use for this cluster + :type type: str or Type """ params = { From 210ebace8ff43d37538d6ca0b545e994d239184d Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 15:08:37 -0400 Subject: [PATCH 047/379] Make review changes --- linode_api4/linode_client.py | 47 +++++++++++++++++++++++---------- linode_api4/objects/database.py | 12 +++++++++ 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index e9ee8a3e5..10ced476c 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1180,7 +1180,7 @@ def mysql_instances(self, *filters): """ return self.client._get_and_filter(MySQLDatabase, *filters) - def mysql_create(self, label, region, engine, type, **kwargs): + def mysql_create(self, label, region, engine, ltype, **kwargs): """ Creates an :any:`MySQLDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -1199,21 +1199,22 @@ def mysql_create(self, label, region, engine, type, **kwargs): engine.id, type.id ) + :param label: The name for this cluster :type label: str :param region: The region to deploy this cluster in :type region: str or Region :param engine: The engine to deploy this cluster with :type engine: str or Engine - :param type: The Linode Type to use for this cluster - :type type: str or Type + :param ltype: The Linode Type to use for this cluster + :type ltype: str or Type """ params = { 'label': label, - 'region': region, - 'engine': engine, - 'type': type, + 'region': region.id if issubclass(type(region), Base) else region, + 'engine': engine.id if issubclass(type(engine), Base) else engine, + 'type': ltype.id if issubclass(type(ltype), Base) else ltype, } params.update(kwargs) @@ -1236,7 +1237,7 @@ def postgresql_instances(self, *filters): """ return self.client._get_and_filter(PostgreSQLDatabase, *filters) - def postgresql_create(self, label, region, engine, type, **kwargs): + def postgresql_create(self, label, region, engine, ltype, **kwargs): """ Creates an :any:`PostgreSQLDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -1255,13 +1256,22 @@ def postgresql_create(self, label, region, engine, type, **kwargs): engine.id, type.id ) + + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str or Region + :param engine: The engine to deploy this cluster with + :type engine: str or Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str or Type """ params = { 'label': label, - 'region': region, - 'engine': engine, - 'type': type, + 'region': region.id if issubclass(type(region), Base) else region, + 'engine': engine.id if issubclass(type(engine), Base) else engine, + 'type': ltype.id if issubclass(type(ltype), Base) else ltype, } params.update(kwargs) @@ -1284,7 +1294,7 @@ def mongodb_instances(self, *filters): """ return self.client._get_and_filter(MongoDBDatabase, *filters) - def mongodb_create(self, label, region, engine, type, **kwargs): + def mongodb_create(self, label, region, engine, ltype, **kwargs): """ Creates an :any:`MongoDBDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -1303,13 +1313,22 @@ def mongodb_create(self, label, region, engine, type, **kwargs): engine.id, type.id ) + + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str or Region + :param engine: The engine to deploy this cluster with + :type engine: str or Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str or Type """ params = { 'label': label, - 'region': region, - 'engine': engine, - 'type': type, + 'region': region.id if issubclass(type(region), Base) else region, + 'engine': engine.id if issubclass(type(engine), Base) else engine, + 'type': ltype.id if issubclass(type(ltype), Base) else ltype, } params.update(kwargs) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index eb75ff38a..79a8d71c5 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -12,6 +12,7 @@ class DatabaseType(Base): 'label': Property(), 'memory': Property(), 'vcpus': Property(), + # type_class is populated from the 'class' attribute of the returned JSON } def _populate(self, json): @@ -81,6 +82,7 @@ def instance(self): engine_type_translation = { 'mysql': MySQLDatabase, 'postgresql': PostgreSQLDatabase, + 'mongodb': MongoDBDatabase, } if self.engine not in engine_type_translation: @@ -103,6 +105,13 @@ def invalidate(self): class DatabaseBackup(DerivedBase): + """ + A generic Managed Database backup. + + This class is not intended to be used on its own. + Use the appropriate subclasses for the corresponding database engine. (e.g. MySQLDatabaseBackup) + """ + api_endpoint = '' derived_url_path = 'backups' parent_id_name = 'database_id' @@ -195,6 +204,9 @@ def patch(self): def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed MySQL Database. + + :param label: The name for this backup + :type label: str """ params = { From bc3b9eae61cfb6c360e6d3924340d34653b7a636 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 15:10:24 -0400 Subject: [PATCH 048/379] Drop backup return --- linode_api4/objects/database.py | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 79a8d71c5..4716d2e1e 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -214,15 +214,9 @@ def backup_create(self, label, **kwargs): } params.update(kwargs) - result = self._client.post('{}/backups'.format(MySQLDatabase.api_endpoint), model=self, data=params) + self._client.post('{}/backups'.format(MySQLDatabase.api_endpoint), model=self, data=params) self.invalidate() - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating backup', json=result) - - b = MySQLDatabaseBackup(self._client, result['id'], self.id, result) - return b - def invalidate(self): """ Clear out cached properties. @@ -304,15 +298,9 @@ def backup_create(self, label, **kwargs): } params.update(kwargs) - result = self._client.post('{}/backups'.format(PostgreSQLDatabase.api_endpoint), model=self, data=params) + self._client.post('{}/backups'.format(PostgreSQLDatabase.api_endpoint), model=self, data=params) self.invalidate() - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating backup', json=result) - - b = PostgreSQLDatabaseBackup(self._client, result['id'], self.id, result) - return b - def invalidate(self): """ Clear out cached properties. @@ -396,15 +384,9 @@ def backup_create(self, label, **kwargs): } params.update(kwargs) - result = self._client.post('{}/backups'.format(MongoDBDatabase.api_endpoint), model=self, data=params) + self._client.post('{}/backups'.format(MongoDBDatabase.api_endpoint), model=self, data=params) self.invalidate() - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating backup', json=result) - - b = MongoDBDatabaseBackup(self._client, result['id'], self.id, result) - return b - def invalidate(self): """ Clear out cached properties. From 01d796af11ea6ad162d0947d4d6737b3eb9f0071 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Wed, 28 Sep 2022 15:21:56 -0400 Subject: [PATCH 049/379] Make ENGINE_TYPE_TRANSLATION constant --- linode_api4/objects/database.py | 115 ++++++++++++++++---------------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 4716d2e1e..04c7997aa 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,4 +1,4 @@ -from linode_api4.objects import Base, Property, MappedObject, DerivedBase, UnexpectedResponseError +from linode_api4.objects import Base, Property, MappedObject, DerivedBase class DatabaseType(Base): @@ -36,62 +36,6 @@ class DatabaseEngine(Base): 'version': Property(filterable=True), } - -class Database(Base): - """ - A generic Database instance. - """ - - api_endpoint = '/databases/instances/{id}' - - properties = { - 'id': Property(), - 'label': Property(), - 'allow_list': Property(), - 'cluster_size': Property(), - 'created': Property(), - 'encrypted': Property(), - 'engine': Property(), - 'hosts': Property(), - 'instance_uri': Property(), - 'region': Property(), - 'status': Property(), - 'type': Property(), - 'updated': Property(), - 'updates': Property(), - 'version': Property(), - } - - @property - def instance(self): - """ - Returns the underlying database object for the corresponding database - engine. This is useful for performing operations on generic databases. - - The following is an example of printing credentials for all databases regardless of engine:: - - client = LinodeClient(TOKEN) - - databases = client.database.instances() - - for db in databases: - print(f"{db.hosts.primary}: {db.instance.credentials.username} {db.instance.credentials.password}") - """ - - if not hasattr(self, '_instance'): - engine_type_translation = { - 'mysql': MySQLDatabase, - 'postgresql': PostgreSQLDatabase, - 'mongodb': MongoDBDatabase, - } - - if self.engine not in engine_type_translation: - return None - - self._set('_instance', engine_type_translation[self.engine](self._client, self.id)) - - return self._instance - def invalidate(self): """ Clear out cached properties. @@ -397,3 +341,60 @@ def invalidate(self): delattr(self, attr) Base.invalidate(self) + + +ENGINE_TYPE_TRANSLATION = { + 'mysql': MySQLDatabase, + 'postgresql': PostgreSQLDatabase, + 'mongodb': MongoDBDatabase, +} + + +class Database(Base): + """ + A generic Database instance. + """ + + api_endpoint = '/databases/instances/{id}' + + properties = { + 'id': Property(), + 'label': Property(), + 'allow_list': Property(), + 'cluster_size': Property(), + 'created': Property(), + 'encrypted': Property(), + 'engine': Property(), + 'hosts': Property(), + 'instance_uri': Property(), + 'region': Property(), + 'status': Property(), + 'type': Property(), + 'updated': Property(), + 'updates': Property(), + 'version': Property(), + } + + @property + def instance(self): + """ + Returns the underlying database object for the corresponding database + engine. This is useful for performing operations on generic databases. + + The following is an example of printing credentials for all databases regardless of engine:: + + client = LinodeClient(TOKEN) + + databases = client.database.instances() + + for db in databases: + print(f"{db.hosts.primary}: {db.instance.credentials.username} {db.instance.credentials.password}") + """ + + if not hasattr(self, '_instance'): + if self.engine not in ENGINE_TYPE_TRANSLATION: + return None + + self._set('_instance', ENGINE_TYPE_TRANSLATION[self.engine](self._client, self.id)) + + return self._instance From 1c65238ed6a41dd011f039b3e4cda7dd58f8c4a9 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Fri, 30 Sep 2022 10:52:24 -0400 Subject: [PATCH 050/379] Bump version -> 5.3.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 05ed6ea1a..ede7e8f41 100755 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ def get_test_suite(): # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.2.1', + version='5.3.0', description='The official python SDK for Linode API v4', long_description=long_description, From 517ce993b67de3e038b0c135f9cfaed9e8ea25b4 Mon Sep 17 00:00:00 2001 From: zliang-akamai <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 9 Feb 2023 13:42:50 -0600 Subject: [PATCH 051/379] Remove Python 3.6 and add Python 3.11 to workflow (#223) * Remove Python 3.6 and add Python 3.11 to workflow * Update tox.ini --- .github/workflows/main.yml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index edb094df9..d2f1033cc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.6','3.7','3.8','3.9','3.10'] + python-version: ['3.7','3.8','3.9','3.10', '3.11'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/tox.ini b/tox.ini index e5a772295..b419801a5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37,py38 +envlist = py37,py38,py39,py310,py311 skip_missing_interpreters = true [testenv] From 4a826bfc1ce672831cec81ea17f68e95cd29e9a1 Mon Sep 17 00:00:00 2001 From: zliang-akamai <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 10 Feb 2023 11:04:14 -0500 Subject: [PATCH 052/379] Client Type Annotation (#222) * Type annotation * Add venv to .gitignore * Apply postponed evaluation of annotations * Remove unused import --- .gitignore | 1 + linode_api4/linode_client.py | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 799d298fa..5007e7880 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ docs/_build/* .coverage .pytest_cache/* .tox/* +venv diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 10ced476c..19f2aba64 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import json import logging -from datetime import datetime import os import time +from datetime import datetime import pkg_resources import requests @@ -11,7 +13,7 @@ from linode_api4.objects import * from linode_api4.objects.filtering import Filter -from .common import load_and_validate_keys, SSH_KEY_TYPES +from .common import SSH_KEY_TYPES, load_and_validate_keys from .paginated_list import PaginatedList package_version = pkg_resources.require("linode_api4")[0].version @@ -20,7 +22,7 @@ class Group: - def __init__(self, client): + def __init__(self, client: LinodeClient): self.client = client From 0e40b041fa8011ad1f88229b062191959558f4f1 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Mon, 3 Apr 2023 13:02:35 -0400 Subject: [PATCH 053/379] Added missing fields to `Volume` object (#228) Added missing fields (`filesystem_path`, `hardware_type`, and `linode_label`) to `Volume` object. Test by running `tox`. Ticket: TPT-1897 --- linode_api4/objects/volume.py | 3 +++ test/fixtures/volumes.json | 15 ++++++++++++--- test/linode_client_test.py | 6 ++++++ test/objects/volume_test.py | 4 ++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 1140e5e80..aef36893c 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -15,6 +15,9 @@ class Volume(Base): 'status': Property(filterable=True), 'region': Property(slug_relationship=Region), 'tags': Property(mutable=True), + 'filesystem_path': Property(), + 'hardware_type': Property(), + 'linode_label': Property(), } def attach(self, to_linode, config=None): diff --git a/test/fixtures/volumes.json b/test/fixtures/volumes.json index 52387a1b0..18ba4f6da 100644 --- a/test/fixtures/volumes.json +++ b/test/fixtures/volumes.json @@ -9,7 +9,10 @@ "size": 40, "updated": "2017-08-04T04:00:00", "status": "active", - "tags": ["something"] + "tags": ["something"], + "filesystem_path": "this/is/a/file/path", + "hardware_type": "hdd", + "linode_label": null }, { "id": 2, @@ -20,7 +23,10 @@ "size": 100, "updated": "2017-08-07T04:00:00", "status": "active", - "tags": [] + "tags": [], + "filesystem_path": "this/is/a/file/path", + "hardware_type": "nvme", + "linode_label": null }, { "id": 3, @@ -31,7 +37,10 @@ "size": 200, "updated": "2017-08-07T04:00:00", "status": "active", - "tags": ["attached"] + "tags": ["attached"], + "filesystem_path": "this/is/a/file/path", + "hardware_type": "nvme", + "linode_label": "some_label" } ], "results": 3, diff --git a/test/linode_client_test.py b/test/linode_client_test.py index b404b1a2d..a6e466324 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -115,6 +115,12 @@ def test_get_volumes(self): self.assertEqual(v[1].size, 100) self.assertEqual(v[2].size, 200) self.assertEqual(v[2].label, 'block3') + self.assertEqual(v[0].filesystem_path, 'this/is/a/file/path') + self.assertEqual(v[0].hardware_type, 'hdd') + self.assertEqual(v[1].filesystem_path, 'this/is/a/file/path') + self.assertEqual(v[1].linode_label, None) + self.assertEqual(v[2].filesystem_path, 'this/is/a/file/path') + self.assertEqual(v[2].hardware_type, 'nvme') assert v[0].tags == ["something"] assert v[1].tags == [] diff --git a/test/objects/volume_test.py b/test/objects/volume_test.py index cc6bd4300..3e967caf0 100644 --- a/test/objects/volume_test.py +++ b/test/objects/volume_test.py @@ -27,6 +27,10 @@ def test_get_volume(self): assert volume.tags == ["something"] + self.assertEqual(volume.filesystem_path, 'this/is/a/file/path') + self.assertEqual(volume.hardware_type, 'hdd') + self.assertEqual(volume.linode_label, None) + def test_update_volume_tags(self): """ Tests that updating tags on an entity send the correct request From ddf4df1fcef3e7b05e246159a3fed1ce507ce343 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Mon, 3 Apr 2023 13:02:48 -0400 Subject: [PATCH 054/379] Added `usd` field to payment object (#227) Test by running `tox`. Ticket: TPT-1878 --- linode_api4/objects/account.py | 2 +- test/fixtures/account_payments.json | 13 +++++++++++++ test/linode_client_test.py | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/account_payments.json diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 474651503..126f0835d 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -224,7 +224,7 @@ class Payment(Base): properties = { "id": Property(identifier=True), "date": Property(is_datetime=True), - "amount": Property(), + "usd": Property(), } diff --git a/test/fixtures/account_payments.json b/test/fixtures/account_payments.json new file mode 100644 index 000000000..f218ae1bc --- /dev/null +++ b/test/fixtures/account_payments.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "id": 123456, + "date": "2015-01-01T05:01:02", + "usd": 1000 + } + ], + "page": 1, + "pages": 1, + "results": 1 + } + \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index a6e466324..2c3003e33 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -239,6 +239,19 @@ def test_get_invoices(self): self.assertEqual(invoice.label, 'Invoice #123456') self.assertEqual(invoice.total, 9.51) + def test_payments(self): + """ + Tests that payments can be retrieved + """ + p = self.client.account.payments() + + self.assertEqual(len(p), 1) + payment = p[0] + + self.assertEqual(payment.id, 123456) + self.assertEqual(payment.date, datetime(2015, 1, 1, 5, 1, 2)) + self.assertEqual(payment.usd, 1000) + class LinodeGroupTest(ClientBaseCase): """ From 2983b12dd219359d851f665c4dd4851090c011ed Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 3 Apr 2023 14:59:08 -0400 Subject: [PATCH 055/379] new: Add standard issue templates, contributing guidelines, and release drafter (#225) This change adds the standard templates for issues, pull requests, and contributing guidelines. Additionally, this pull request adds a release drafter workflow. --- .github/ISSUE_TEMPLATE/bug.yml | 37 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature.yml | 12 +++++++ .github/ISSUE_TEMPLATE/help.yml | 12 +++++++ .github/pull_request_template.md | 13 +++++++ .github/release-drafter.yml | 21 ++++++++++++ .github/workflows/release-drafter.yml | 16 +++++++++ CONTRIBUTING.md | 49 +++++++++++++++++++++++++++ README.rst | 7 ++++ 8 files changed, 167 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/ISSUE_TEMPLATE/help.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..7958c0e64 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,37 @@ +name: Bug Report +description: File a bug report +title: "[Bug]: " +labels: ["bug"] +body: + - type: input + id: package-version + attributes: + label: Package + description: What version of the linode_api4 package are you using? + placeholder: 5.3.0 + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What should have happened? + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: List any custom configurations and the steps to reproduce this error + + - type: textarea + id: error + attributes: + label: Error Output + description: If you received an error output that is too long, use Gists \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..e375d78a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,12 @@ +name: Enhancement +description: Request a feature +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: Description + description: What would you like this feature to do in detail? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/help.yml b/.github/ISSUE_TEMPLATE/help.yml new file mode 100644 index 000000000..e822ee980 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help.yml @@ -0,0 +1,12 @@ +name: Help +description: You're pretty sure it's not a bug but you can't figure out why it's not working +title: "[Help]: " +labels: ["help wanted"] +body: + - type: textarea + id: description + attributes: + label: Description + description: What are you attempting to do, what error messages are you getting? + validations: + required: true \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..5bea77b2c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## 📝 Description + +**What does this PR do and why is this change necessary?** + +## ✔️ How to Test + +**What are the steps to reproduce the issue or verify the changes?** + +**How do I run the relevant unit/integration tests?** + +## 📷 Preview + +**If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 000000000..794521367 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,21 @@ +name-template: 'v$NEXT_PATCH_VERSION' +tag-template: 'v$NEXT_PATCH_VERSION' +categories: + - title: '🚀 Added' + label: 'added-feature' + - title: '🧰 Changed' + label: 'changed' + - title: "⚠️ Deprecated" + label: "deprecated" + - title: "⚠️ Removed" + label: "removed" + - title: '🐛 Bug Fixes' + label: 'bugfix' + - title: "⚠️ Security" + label: "security" +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +no-changes-template: "- No changes" +template: | + ## Changes + + $CHANGES \ No newline at end of file diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 000000000..b4207e7d4 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,16 @@ +name: Release Drafter + +on: + push: + branches: + - main # Preemptive for branch change + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@569eb7ee3a85817ab916c8f8ff03a5bd96c9c83e # pin@v5 + with: + config-name: release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..0a5403963 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing Guidelines + +:+1::tada: First off, we appreciate you taking the time to contribute! THANK YOU! :tada::+1: + +We put together the handy guide below to help you get support for your work. Read on! + +## I Just Want to Ask the Maintainers a Question + +The [Linode Community](https://www.linode.com/community/questions/) is a great place to get additional support. + +## How Do I Submit A (Good) Bug Report or Feature Request + +Please open a [GitHub issue](../../issues/new/choose) to report bugs or suggest features. + +Please accurately fill out the appropriate GitHub issue form. + +When filing an issue or feature request, help us avoid duplication and redundant effort -- check existing open or recently closed issues first. + +Detailed bug reports and requests are easier for us to work with. Please include the following in your issue: + +* A reproducible test case or series of steps +* The version of our code being used +* Any modifications you've made, relevant to the bug +* Anything unusual about your environment or deployment +* Screenshots and code samples where illustrative and helpful + +## How to Open a Pull Request + +We follow the [fork and pull model](https://opensource.guide/how-to-contribute/#opening-a-pull-request) for open source contributions. + +Tips for a faster merge: +* address one feature or bug per pull request. +* large formatting changes make it hard for us to focus on your work. +* follow language coding conventions. +* make sure that tests pass. +* make sure your commits are atomic, [addressing one change per commit](https://chris.beams.io/posts/git-commit/). +* add tests! + +## Code of Conduct + +This project follows the [Linode Community Code of Conduct](https://www.linode.com/community/questions/conduct). + +## Vulnerability Reporting + +If you discover a potential security issue in this project we ask that you notify Linode Security via our [vulnerability reporting process](https://hackerone.com/linode). Please do **not** create a public github issue. + +## Licensing + +See the [LICENSE file](/LICENSE) for our project's licensing. \ No newline at end of file diff --git a/README.rst b/README.rst index 69eeda3aa..a95a96982 100644 --- a/README.rst +++ b/README.rst @@ -103,3 +103,10 @@ documentation for this library is out of date or unclear, please .. _Sphinx: http://www.sphinx-doc.org/en/master/index.html .. _open an issue: https://github.com/linode/linode_api4-python/issues/new + +Contributing +------------ + +Please follow the `Contributing Guidelines`_ when making a contribution. + +.. _Contributing Guidelines: https://github.com/linode/linode_api4-python/blob/master/CONTRIBUTING.md From 1378ca8276bb97f08d97dca4294827a847aaa6b3 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Mon, 3 Apr 2023 15:45:17 -0400 Subject: [PATCH 056/379] Added missing `successor` field to `Type` object (#229) Test with `tox`. Ticket: TPT-1887 --- linode_api4/objects/linode.py | 1 + test/fixtures/linode_types.json | 12 ++++++++---- test/objects/linode_test.py | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index ccdbbb8d8..261a6727d 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -144,6 +144,7 @@ class Type(Base): 'transfer': Property(filterable=True), 'vcpus': Property(filterable=True), 'gpus': Property(filterable=True), + 'successor': Property(), # type_class is populated from the 'class' attribute of the returned JSON } diff --git a/test/fixtures/linode_types.json b/test/fixtures/linode_types.json index 9de6a7d90..b270da778 100644 --- a/test/fixtures/linode_types.json +++ b/test/fixtures/linode_types.json @@ -24,7 +24,8 @@ "price": { "hourly": 0.0075, "monthly": 5 - } + }, + "successor": null }, { "disk": 20480, @@ -47,7 +48,8 @@ "price": { "hourly": 0.09, "monthly": 60 - } + }, + "successor": null }, { "disk": 30720, @@ -70,7 +72,8 @@ "price": { "hourly": 0.015, "monthly": 10 - } + }, + "successor": null }, { "disk": 49152, @@ -93,7 +96,8 @@ "price": { "hourly": 0.03, "monthly": 20 - } + }, + "successor": null } ] } diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index 4a269fec6..20fdcac2a 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -326,6 +326,7 @@ def test_get_types(self): self.assertIsNotNone(t.disk) self.assertIsNotNone(t.type_class) self.assertIsNotNone(t.gpus) + self.assertIsNone(t.successor) def test_get_type_by_id(self): """ From 3e78a74b0a9ad35197d803821fd963b3b53b63f5 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 4 Apr 2023 13:43:47 -0400 Subject: [PATCH 057/379] new: Bring Image-related functionality to API parity (#226) This change adds missing fields to the `Image` object and adds two new functions: - `client.image_create_upload(...)` - Creates/returns and image and the corresponding upload URL - `client.image_upload(...)` - Takes a BinaryIO stream, creates an image, and uploads to the image. These are all of the necessary changes to bring image-related functionality to API parity. --- linode_api4/linode_client.py | 63 ++++++++++++++++++++++ linode_api4/objects/image.py | 3 ++ linode_api4/util.py | 24 +++++++++ requirements-dev.txt | 5 ++ requirements.txt | 1 + test/fixtures/images.json | 20 +++++-- test/fixtures/images_private_1337.json | 16 ++++++ test/fixtures/images_upload.json | 19 +++++++ test/objects/image_test.py | 74 ++++++++++++++++++++++++++ test/util_test.py | 66 +++++++++++++++++++++++ 10 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 linode_api4/util.py create mode 100644 requirements-dev.txt create mode 100644 test/fixtures/images_private_1337.json create mode 100644 test/fixtures/images_upload.json create mode 100644 test/util_test.py diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 19f2aba64..d7b987c71 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -5,6 +5,7 @@ import os import time from datetime import datetime +from typing import Tuple, BinaryIO import pkg_resources import requests @@ -15,6 +16,7 @@ from .common import SSH_KEY_TYPES, load_and_validate_keys from .paginated_list import PaginatedList +from .util import drop_null_keys package_version = pkg_resources.require("linode_api4")[0].version @@ -1618,6 +1620,67 @@ def image_create(self, disk, label=None, description=None): return Image(self, result['id'], result) + def image_create_upload(self, label: str, region: str, description: str=None) -> Tuple[Image, str]: + """ + Creates a new Image and returns the corresponding upload URL. + https://www.linode.com/docs/api/images/#image-upload + + :param label: The label of the Image to create. + :type label: str + :param region: The region to upload to. Once the image has been created, it can be used in any region. + :type region: str + :param description: The description for the new Image. + :type description: str + + :returns: A tuple containing the new image and the image upload URL. + :rtype: (Image, str) + """ + params = { + "label": label, + "region": region, + "description": description + } + + result = self.post("/images/upload", data=drop_null_keys(params)) + + if "image" not in result: + raise UnexpectedResponseError('Unexpected response when creating an ' + 'Image upload URL') + + result_image = result["image"] + result_url = result["upload_to"] + + return Image(self, result_image["id"], result_image), result_url + + def image_upload(self, label: str, region: str, file: BinaryIO, description: str=None) -> Image: + """ + Creates and uploads a new image. + https://www.linode.com/docs/api/images/#image-upload + + :param label: The label of the Image to create. + :type label: str + :param region: The region to upload to. Once the image has been created, it can be used in any region. + :type region: str + :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb"). + :param description: The description for the new Image. + :type description: str + + :returns: The resulting image. + :rtype: Image + """ + + image, url = self.image_create_upload(label, region, description=description) + + requests.put( + url, + headers={"Content-Type": "application/octet-stream"}, + data=file, + ) + + image._api_get() + + return image + def domains(self, *filters): """ Retrieves all of the Domains the acting user has access to. diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 408697647..a44246ae8 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -11,9 +11,12 @@ class Image(Base): "id": Property(identifier=True), "label": Property(mutable=True), "description": Property(mutable=True), + "eol": Property(is_datetime=True), + "expiry": Property(is_datetime=True), "status": Property(), "created": Property(is_datetime=True), "created_by": Property(), + "updated": Property(is_datetime=True), "type": Property(), "is_public": Property(), "vendor": Property(), diff --git a/linode_api4/util.py b/linode_api4/util.py new file mode 100644 index 000000000..8dac5d35a --- /dev/null +++ b/linode_api4/util.py @@ -0,0 +1,24 @@ +""" +Contains various utility functions. +""" +from typing import Dict, Any + + +def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]: + """ + Traverses a dict and drops any keys that map to None values. + """ + + if not recursive: + return {k: v for k, v in data.items() if v is not None} + + def recursive_helper(value: Any) -> Any: + if isinstance(value, dict): + return {k: recursive_helper(v) for k, v in value.items() if v is not None} + + if isinstance(value, list): + return [recursive_helper(v) for v in value] + + return value + + return recursive_helper(data) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000..0e3856ab2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,5 @@ +mock>=5.0.0 +tox>=4.4.0 +Sphinx>=6.0.0 +sphinx-autobuild>=2021.3.14 +sphinxcontrib-fulltoc>=1.2.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 00afeec47..9fe34db3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ httplib2 enum34 +requests \ No newline at end of file diff --git a/test/fixtures/images.json b/test/fixtures/images.json index 4fe432130..69e5c3380 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -14,7 +14,10 @@ "size": 1100, "is_public": true, "type": "manual", - "vendor": "Debian" + "vendor": "Debian", + "eol": "2026-07-01T04:00:00", + "expiry": "2026-08-01T04:00:00", + "updated": "2020-07-01T04:00:00" }, { "created": "2017-01-01T00:01:01", @@ -27,7 +30,10 @@ "size": 1500, "is_public": true, "type": "manual", - "vendor": "Ubuntu" + "vendor": "Ubuntu", + "eol": "2026-07-01T04:00:00", + "expiry": "2026-08-01T04:00:00", + "updated": "2020-07-01T04:00:00" }, { "created": "2017-01-01T00:01:01", @@ -40,7 +46,10 @@ "size": 1500, "is_public": true, "type": "manual", - "vendor": "Fedora" + "vendor": "Fedora", + "eol": "2026-07-01T04:00:00", + "expiry": "2026-08-01T04:00:00", + "updated": "2020-07-01T04:00:00" }, { "created": "2017-08-20T14:01:01", @@ -53,7 +62,10 @@ "size": 650, "is_public": false, "type": "manual", - "vendor": null + "vendor": null, + "eol": "2026-07-01T04:00:00", + "expiry": "2026-08-01T04:00:00", + "updated": "2020-07-01T04:00:00" } ] } diff --git a/test/fixtures/images_private_1337.json b/test/fixtures/images_private_1337.json new file mode 100644 index 000000000..f8864f4a1 --- /dev/null +++ b/test/fixtures/images_private_1337.json @@ -0,0 +1,16 @@ +{ + "created": "2021-08-14T22:44:02", + "created_by": "someone", + "deprecated": false, + "description": "very real image upload.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "private/1337", + "is_public": false, + "label": "Realest Image Upload", + "size": 2500, + "status": "available", + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": "Debian" +} \ No newline at end of file diff --git a/test/fixtures/images_upload.json b/test/fixtures/images_upload.json new file mode 100644 index 000000000..cafda237b --- /dev/null +++ b/test/fixtures/images_upload.json @@ -0,0 +1,19 @@ +{ + "image": { + "created": "2021-08-14T22:44:02", + "created_by": "someone", + "deprecated": false, + "description": "very real image upload.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "private/1337", + "is_public": false, + "label": "Realest Image Upload", + "size": 2500, + "status": "available", + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": "Debian" + }, + "upload_to": "https://linode.com/" +} \ No newline at end of file diff --git a/test/objects/image_test.py b/test/objects/image_test.py index a6ec9a297..6e9710646 100644 --- a/test/objects/image_test.py +++ b/test/objects/image_test.py @@ -1,8 +1,20 @@ +from datetime import datetime +from io import BytesIO +from typing import Any, BinaryIO +from unittest.mock import patch + from test.base import ClientBaseCase from linode_api4.objects import Image +# A minimal gzipped image that will be accepted by the API +TEST_IMAGE_CONTENT = ( + b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" + b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" +) + + class ImageTest(ClientBaseCase): """ Tests methods of the Image class @@ -24,3 +36,65 @@ def test_get_image(self): self.assertEqual(image.type, "manual") self.assertEqual(image.created_by, "linode") self.assertEqual(image.size, 1100) + + self.assertEqual(image.eol, datetime( + year=2026, month=7, day=1, hour=4, minute=0, second=0 + )) + + self.assertEqual(image.expiry, datetime( + year=2026, month=8, day=1, hour=4, minute=0, second=0 + )) + + self.assertEqual(image.updated, datetime( + year=2020, month=7, day=1, hour=4, minute=0, second=0 + )) + + def test_image_create_upload(self): + """ + Test that an image upload URL can be created successfully. + """ + + with self.mock_post("/images/upload") as m: + image, url = self.client.image_create_upload( + "Realest Image Upload", + "us-southeast", + description="very real image upload.", + ) + + self.assertEqual(m.call_url, "/images/upload") + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_data, + { + "label": "Realest Image Upload", + "region": "us-southeast", + "description": "very real image upload." + } + ) + + self.assertEqual(image.id, "private/1337") + self.assertEqual(image.label, "Realest Image Upload") + self.assertEqual(image.description, "very real image upload.") + + self.assertEqual(url, "https://linode.com/") + + def test_image_upload(self): + """ + Test that an image can be uploaded. + """ + + def put_mock(url: str, data: BinaryIO = None, **kwargs): + self.assertEqual(url, "https://linode.com/") + self.assertEqual(data.read(), TEST_IMAGE_CONTENT) + + with patch("requests.put", put_mock), self.mock_post("/images/upload"): + image = self.client.image_upload( + "Realest Image Upload", + "us-southeast", + BytesIO(TEST_IMAGE_CONTENT), + description="very real image upload.", + ) + + self.assertEqual(image.id, "private/1337") + self.assertEqual(image.label, "Realest Image Upload") + self.assertEqual(image.description, "very real image upload.") \ No newline at end of file diff --git a/test/util_test.py b/test/util_test.py new file mode 100644 index 000000000..cdee919ab --- /dev/null +++ b/test/util_test.py @@ -0,0 +1,66 @@ +import unittest + +from linode_api4.util import drop_null_keys + + +class UtilTest(unittest.TestCase): + """ + Tests for utility functions. + """ + + def test_drop_null_keys_nonrecursive(self): + """ + Tests whether a non-recursive drop_null_keys call works as expected. + """ + value = { + "foo": "bar", + "test": None, + "cool": { + "test": "bar", + "cool": None, + } + } + + expected_output = { + "foo": "bar", + "cool": { + "test": "bar", + "cool": None + } + } + + assert drop_null_keys(value, recursive=False) == expected_output + + def test_drop_null_keys_recursive(self): + """ + Tests whether a recursive drop_null_keys call works as expected. + """ + + value = { + "foo": "bar", + "test": None, + "cool": { + "test": "bar", + "cool": None, + "list": [ + { + "foo": "bar", + "test": None + } + ] + } + } + + expected_output = { + "foo": "bar", + "cool": { + "test": "bar", + "list": [ + { + "foo": "bar", + } + ] + } + } + + assert drop_null_keys(value) == expected_output From f88dac074b98ece7a9ef6780fd3e00e74e509234 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 4 Apr 2023 14:10:37 -0400 Subject: [PATCH 058/379] Workflow changes for branch update --- .github/workflows/main.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d2f1033cc..8c425fe13 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,9 +3,10 @@ name: Test Suite on: push: - branches: [ master ] + branches: + - dev + - main pull_request: - branches: [ master ] workflow_dispatch: jobs: From 2fdb3e0df9f93f2fd83eaf588c9b7ff748ced8a3 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 10 Apr 2023 12:04:15 -0400 Subject: [PATCH 059/379] doc: Add a Quick Start section to the README (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds a quick start section to the project's README file. This section should reduce friction for new users by providing a usage example without the need to read through the full Getting Started guide. --- README.rst | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index a95a96982..89e5a6ef2 100644 --- a/README.rst +++ b/README.rst @@ -30,11 +30,60 @@ To build and install this package: - ``./setup.py install`` Usage ------ +===== + +Quick Start +----------- + +In order to authenticate with the Linode API, you will first need to create a +`Linode Personal Access Token`_ with your desired account permissions. + +The following code sample can help you quickly get started using this package. + +.. code-block:: python + + from linode_api4 import LinodeClient, Instance + + # Create a Linode API client + client = LinodeClient("MY_PERSONAL_ACCESS_TOKEN") + + # Create a new Linode + new_linode, root_pass = client.linode.instance_create( + ltype="g6-nanode-1", + region="us-southeast", + image="linode/ubuntu22.04", + label="my-ubuntu-linode" + ) + + # Print info about the Linode + print("Linode IP:", new_linode.ipv4[0]) + print("Linode Root Password:", root_pass) + + # List all Linodes on the account + my_linodes = client.linode.instances() + + # Print the Label of every Linode on the account + print("All Instances:") + for instance in my_linodes: + print(instance.label) + + # List Linodes in the us-southeast region + specific_linodes = client.linode.instances( + Instance.region == "us-southeast" + ) + + # Print the label of each Linode in us-southeast + print("Instances in us-southeast:") + for instance in specific_linodes: + print(instance.label) + + # Delete the new instance + new_linode.delete() -Check out the `Getting Started guide`_ to start using this library, or read -`the docs`_ for extensive documentation. +Check out the `Getting Started guide`_ for more details on getting started +with this library, or read `the docs`_ for more extensive documentation. +.. _Linode Personal Access Token: https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/ .. _Getting Started guide: http://linode_api4.readthedocs.io/en/latest/guides/getting_started.html .. _the docs: http://linode_api4.readthedocs.io/en/latest/index.html From 37bc0339d3745dfdf5593f6eabe6d0047bbd188d Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 11 Apr 2023 14:34:44 -0400 Subject: [PATCH 060/379] new: Add `force` keyword argument to `Base.save()` function (#232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds an optional `force` argument to the `Base.save()` method. This was previously the default functionality, which could result in a large number of redundant PUT requests. If `force` is set to `False`, the API request will only be made if a field has been explicitly updated. ## ✔️ How to Test ``` pytest test/objects/linode_test.py ``` --- linode_api4/objects/base.py | 28 ++++++++++++++++++----- test/base.py | 7 +++++- test/objects/linode_test.py | 44 +++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 5d1072cf4..eb8a0e997 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -82,6 +82,7 @@ def __init__(self, client, id, json={}): self._set('_populated', False) self._set('_last_updated', datetime.min) self._set('_client', client) + self._set('_changed', False) #: self._raw_json is a copy of the json received from the API on population, #: and cannot be relied upon to be current. Local changes to mutable fields @@ -144,20 +145,36 @@ def __setattr__(self, name, value): """ Enforces allowing editing of only Properties defined as mutable """ - if name in type(self).properties.keys() and not type(self).properties[name].mutable: - raise AttributeError("'{}' is not a mutable field of '{}'" - .format(name, type(self).__name__)) + if name in type(self).properties.keys(): + if not type(self).properties[name].mutable: + raise AttributeError("'{}' is not a mutable field of '{}'" + .format(name, type(self).__name__)) + + self._changed = True + self._set(name, value) - def save(self): + def save(self, force=True) -> bool: """ - Send this object's mutable values to the server in a PUT request + Send this object's mutable values to the server in a PUT request. + + :param force: If true, this method will always send a PUT request regardless of + whether the field has been explicitly updated. For optimization + purposes, this field should be set to false for typical update + operations. (Defaults to True) + :type force: bool """ + if not force and not self._changed: + return False + resp = self._client.put(type(self).api_endpoint, model=self, data=self._serialize()) if 'error' in resp: return False + + self._set('_changed', False) + return True def delete(self): @@ -216,6 +233,7 @@ def _populate(self, json): # hide the raw JSON away in case someone needs it self._set('_raw_json', json) + self._set('_updated', False) for key in json: if key in (k for k in type(self).properties.keys() diff --git a/test/base.py b/test/base.py index 1dd39d528..6e0b70984 100644 --- a/test/base.py +++ b/test/base.py @@ -98,7 +98,6 @@ def call_data_raw(self): """ return self.mock.call_args[1]['data'] - @property def call_url(self): """ @@ -125,6 +124,12 @@ def call_headers(self): """ return self.mock.call_args[1]['headers'] + @property + def called(self): + """ + A shortcut to check whether the mock function was called. + """ + return self.mock.called class ClientBaseCase(TestCase): def setUp(self): diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index 20fdcac2a..a7e2ea1fb 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -350,3 +350,47 @@ def test_get_type_gpu(self): self.assertEqual(t.gpus, 1) self.assertEqual(t._populated, True) + + def test_save_noforce(self): + """ + Tests that a client will only save if changes are detected + """ + linode = Instance(self.client, 123) + self.assertEqual(linode._populated, False) + + self.assertEqual(linode.label, "linode123") + self.assertEqual(linode.group, "test") + + assert not linode._changed + + with self.mock_put("linode/instances") as m: + linode.save(force=False) + assert not m.called + + linode.label = "blah" + assert linode._changed + + with self.mock_put("linode/instances") as m: + linode.save(force=False) + assert m.called + assert m.call_url == "/linode/instances/123" + assert m.call_data["label"] == "blah" + + assert not linode._changed + + def test_save_force(self): + """ + Tests that a client will forcibly save by default + """ + linode = Instance(self.client, 123) + self.assertEqual(linode._populated, False) + + self.assertEqual(linode.label, "linode123") + self.assertEqual(linode.group, "test") + + assert not linode._changed + + with self.mock_put("linode/instances") as m: + linode.save() + assert m.called + From 6225a3edc0d2753d05270e8df68ccece65c6550d Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Tue, 11 Apr 2023 14:45:58 -0400 Subject: [PATCH 061/379] Added missing endpoints and fields for `Instance` (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Brought instance-related functionality to API parity. ## ✔️ How to Test Run `tox`. Ticket: TPT-1886 --- linode_api4/objects/linode.py | 110 +++++++++++++---- test/fixtures/linode_instances.json | 8 +- .../linode_instances_123_backups.json | 9 +- ...inode_instances_123_disks_12345_clone.json | 10 ++ .../linode_instances_123_firewalls.json | 56 +++++++++ test/fixtures/linode_instances_123_ips.json | 87 +++++++++++++ .../linode_instances_123_nodebalancers.json | 27 ++++ .../linode_instances_123_transfer_2023_4.json | 6 + .../linode_instances_123_volumes.json | 24 ++++ test/objects/linode_test.py | 116 +++++++++++++++++- 10 files changed, 425 insertions(+), 28 deletions(-) create mode 100644 test/fixtures/linode_instances_123_disks_12345_clone.json create mode 100644 test/fixtures/linode_instances_123_firewalls.json create mode 100644 test/fixtures/linode_instances_123_ips.json create mode 100644 test/fixtures/linode_instances_123_nodebalancers.json create mode 100644 test/fixtures/linode_instances_123_transfer_2023_4.json create mode 100644 test/fixtures/linode_instances_123_volumes.json diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 261a6727d..7772f604e 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -5,6 +5,7 @@ from os import urandom from random import randint +from linode_api4 import util from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, Image, Property, Region @@ -35,6 +36,7 @@ class Backup(DerivedBase): 'configs': Property(), 'disks': Property(), 'region': Property(slug_relationship=Region), + 'available': Property() } def restore_to(self, linode, **kwargs): @@ -66,13 +68,12 @@ class Disk(DerivedBase): def duplicate(self): - result = self._client.post(Disk.api_endpoint, model=self, data={}) + d = self._client.post('{}/clone'.format(Disk.api_endpoint), model=self) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response duplicating disk!', json=result) - - d = Disk(self._client, result['id'], self.linode_id, result) - return d + if not 'id' in d: + raise UnexpectedResponseError('Unexpected response duplicating disk!', json=d) + + return Disk(self._client, d["id"], self.linode_id) def reset_root_password(self, root_password=None): @@ -84,15 +85,8 @@ def reset_root_password(self, root_password=None): 'password': rpass, } - result = self._client.post(Disk.api_endpoint, model=self, data=params) + self._client.post('{}/password'.format(Disk.api_endpoint), model=self, data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response duplicating disk!', json=result) - - self._populate(result) - if not root_password: - return True, rpass - return True def resize(self, new_size): """ @@ -128,6 +122,8 @@ class Kernel(Base): "version": Property(filterable=True), "architecture": Property(filterable=True), "xen": Property(filterable=True), + "built": Property(), + "pvops": Property(filterable=True) } @@ -223,6 +219,7 @@ class Config(DerivedBase): "virt_mode": Property(mutable=True, filterable=True), "memory_limit": Property(mutable=True, filterable=True), "interfaces": Property(mutable=True), # gets setup in _populate below + "helpers": Property(mutable=True) } def _populate(self, json): @@ -298,6 +295,8 @@ class Instance(Base): 'hypervisor': Property(), 'specs': Property(), 'tags': Property(mutable=True), + 'host_uuid': Property(), + 'watchdog_enabled': Property(), } @property @@ -327,20 +326,24 @@ def ips(self): i = IPAddress(self._client, c['address'], c) shared_ips.append(i) + reserved = [] + for c in result['ipv4']['reserved']: + i = IPAddress(self._client, c['address'], c) + reserved.append(i) + slaac = IPAddress(self._client, result['ipv6']['slaac']['address'], result['ipv6']['slaac']) link_local = IPAddress(self._client, result['ipv6']['link_local']['address'], result['ipv6']['link_local']) - pools = [] - for p in result['ipv6']['global']: - pools.append(IPv6Pool(self._client, p['range'])) + pools = [IPv6Pool(self._client, result['ipv6']['global']['range'])] ips = MappedObject(**{ "ipv4": { "public": v4pub, "private": v4pri, "shared": shared_ips, + "reserved": reserved, }, "ipv6": { "slaac": slaac, @@ -388,6 +391,28 @@ def available_backups(self): })) return self._avail_backups + + def reset_instance_root_password(self, root_password=None): + rpass = root_password + if not rpass: + rpass = Instance.generate_root_password() + + params = { + 'password': rpass, + } + + self._client.post('{}/password'.format(Instance.api_endpoint), model=self, data=params) + + + def transfer_year_month(self, year, month): + """ + Get per-linode transfer for specified month + """ + + result = self._client.get('{}/transfer/{}/{}'.format(Instance.api_endpoint, year, month), model=self) + + return MappedObject(**result) + @property def transfer(self): @@ -765,20 +790,63 @@ def kvmify(self): return True - def mutate(self): + def mutate(self, allow_auto_disk_resize=True): """ Upgrades this Instance to the latest generation type """ - self._client.post('{}/mutate'.format(Instance.api_endpoint), model=self) + + params = { + "allow_auto_disk_resize": allow_auto_disk_resize + } + + self._client.post('{}/mutate'.format(Instance.api_endpoint), model=self, data=params) return True - def initiate_migration(self): + def initiate_migration(self, region=None, upgrade=None): """ Initiates a pending migration that is already scheduled for this Linode Instance """ - self._client.post('{}/migrate'.format(Instance.api_endpoint), model=self) + params = { + "region": region.id if issubclass(type(region), Base) else region, + "upgrade": upgrade + } + + util.drop_null_keys(params) + + self._client.post('{}/migrate'.format(Instance.api_endpoint), model=self, data=params) + + def firewalls(self): + """ + View Firewall information for Firewalls associated with this Linode. + """ + from linode_api4.objects import Firewall # pylint: disable=import-outside-toplevel + + result = self._client.get('{}/firewalls'.format(Instance.api_endpoint), model=self) + + return [Firewall(self._client, firewall["id"]) for firewall in result["data"]] + + + def nodebalancers(self): + """ + View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. + """ + from linode_api4.objects import NodeBalancer # pylint: disable=import-outside-toplevel + + result = self._client.get('{}/nodebalancers'.format(Instance.api_endpoint), model=self) + + return [NodeBalancer(self._client, nodebalancer["id"]) for nodebalancer in result["data"]] + + def volumes(self): + """ + View Block Storage Volumes attached to this Linode. + """ + from linode_api4.objects import Volume # pylint: disable=import-outside-toplevel + + result = self._client.get('{}/volumes'.format(Instance.api_endpoint), model=self) + + return [Volume(self._client, volume["id"]) for volume in result["data"]] def clone(self, to_linode=None, region=None, service=None, configs=[], disks=[], label=None, group=None, with_backups=None): diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 4ebf4395a..efb502e7e 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -38,7 +38,9 @@ ], "updated": "2017-01-01T00:00:00", "image": "linode/ubuntu17.04", - "tags": ["something"] + "tags": ["something"], + "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true }, { "group": "test", @@ -75,7 +77,9 @@ ], "updated": "2017-01-01T00:00:00", "image": "linode/debian9", - "tags": [] + "tags": [], + "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": false } ] } diff --git a/test/fixtures/linode_instances_123_backups.json b/test/fixtures/linode_instances_123_backups.json index 964dbe883..94fe7f3b7 100644 --- a/test/fixtures/linode_instances_123_backups.json +++ b/test/fixtures/linode_instances_123_backups.json @@ -23,7 +23,8 @@ "id": 12345, "status": "successful", "created": "2018-01-09T00:01:01", - "type": "auto" + "type": "auto", + "available": true }, { "region": "us-east-1a", @@ -48,7 +49,8 @@ "id": 12456, "status": "successful", "created": "2018-01-01T00:01:01", - "type": "auto" + "type": "auto", + "available": true }, { "region": "us-east-1a", @@ -73,7 +75,8 @@ "id": 12567, "status": "successful", "created": "2018-01-07T00:01:01", - "type": "auto" + "type": "auto", + "available": false } ], "snapshot": { diff --git a/test/fixtures/linode_instances_123_disks_12345_clone.json b/test/fixtures/linode_instances_123_disks_12345_clone.json new file mode 100644 index 000000000..2d378edca --- /dev/null +++ b/test/fixtures/linode_instances_123_disks_12345_clone.json @@ -0,0 +1,10 @@ +{ + "size": 25088, + "status": "ready", + "filesystem": "ext4", + "id": 12345, + "updated": "2017-01-01T00:00:00", + "label": "Ubuntu 17.04 Disk", + "created": "2017-01-01T00:00:00" + } + \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_firewalls.json b/test/fixtures/linode_instances_123_firewalls.json new file mode 100644 index 000000000..17a4a9199 --- /dev/null +++ b/test/fixtures/linode_instances_123_firewalls.json @@ -0,0 +1,56 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 123, + "label": "firewall123", + "rules": { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP", + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP" + }, + "status": "enabled", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/linode_instances_123_ips.json b/test/fixtures/linode_instances_123_ips.json new file mode 100644 index 000000000..8b9e64af0 --- /dev/null +++ b/test/fixtures/linode_instances_123_ips.json @@ -0,0 +1,87 @@ +{ + "ipv4": { + "private": [ + { + "address": "192.168.133.234", + "gateway": null, + "linode_id": 123, + "prefix": 17, + "public": false, + "rdns": null, + "region": "us-east", + "subnet_mask": "255.255.128.0", + "type": "ipv4" + } + ], + "public": [ + { + "address": "97.107.143.141", + "gateway": "97.107.143.1", + "linode_id": 123, + "prefix": 24, + "public": true, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4" + } + ], + "reserved": [ + { + "address": "97.107.143.141", + "gateway": "97.107.143.1", + "linode_id": 123, + "prefix": 24, + "public": true, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4" + } + ], + "shared": [ + { + "address": "97.107.143.141", + "gateway": "97.107.143.1", + "linode_id": 123, + "prefix": 24, + "public": true, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4" + } + ] + }, + "ipv6": { + "global": { + "prefix": 124, + "range": "2600:3c01::2:5000:0", + "region": "us-east", + "route_target": "2600:3c01::2:5000:f" + }, + "link_local": { + "address": "fe80::f03c:91ff:fe24:3a2f", + "gateway": "fe80::1", + "linode_id": 123, + "prefix": 64, + "public": false, + "rdns": null, + "region": "us-east", + "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "type": "ipv6" + }, + "slaac": { + "address": "2600:3c03::f03c:91ff:fe24:3a2f", + "gateway": "fe80::1", + "linode_id": 123, + "prefix": 64, + "public": true, + "rdns": null, + "region": "us-east", + "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "type": "ipv6" + } + } + } + \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_nodebalancers.json b/test/fixtures/linode_instances_123_nodebalancers.json new file mode 100644 index 000000000..821ff4801 --- /dev/null +++ b/test/fixtures/linode_instances_123_nodebalancers.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "client_conn_throttle": 0, + "created": "2018-01-01T00:01:01", + "hostname": "192.0.2.1.ip.linodeusercontent.com", + "id": 12345, + "ipv4": "203.0.113.1", + "ipv6": null, + "label": "balancer12345", + "region": "us-east", + "tags": [ + "example tag", + "another example" + ], + "transfer": { + "in": 28.91200828552246, + "out": 3.5487728118896484, + "total": 32.46078109741211 + }, + "updated": "2018-03-01T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/linode_instances_123_transfer_2023_4.json b/test/fixtures/linode_instances_123_transfer_2023_4.json new file mode 100644 index 000000000..3b9397efa --- /dev/null +++ b/test/fixtures/linode_instances_123_transfer_2023_4.json @@ -0,0 +1,6 @@ +{ + "bytes_in": 30471077120, + "bytes_out": 22956600198, + "bytes_total": 53427677318 + } + \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_volumes.json b/test/fixtures/linode_instances_123_volumes.json new file mode 100644 index 000000000..63038e042 --- /dev/null +++ b/test/fixtures/linode_instances_123_volumes.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "filesystem_path": "/dev/disk/by-id/scsi-0Linode_Volume_my-volume", + "hardware_type": "nvme", + "id": 12345, + "label": "my-volume", + "linode_id": 12346, + "linode_label": "linode123", + "region": "us-east", + "size": 30, + "status": "active", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-01T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index a7e2ea1fb..6eb18703e 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -2,8 +2,6 @@ from test.base import ClientBaseCase from linode_api4.objects import Config, Disk, Image, Instance, Type -from linode_api4.objects.base import MappedObject - class LinodeTest(ClientBaseCase): """ @@ -21,6 +19,8 @@ def test_get_linode(self): self.assertTrue(isinstance(linode.image, Image)) self.assertEqual(linode.image.label, "Ubuntu 17.04") + self.assertEqual(linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8") + self.assertEqual(linode.watchdog_enabled, True) json = linode._raw_json self.assertIsNotNone(json) @@ -95,6 +95,7 @@ def test_available_backups(self): self.assertEqual(b.region.id, 'us-east-1a') self.assertEqual(b.label, None) self.assertEqual(b.message, None) + self.assertEqual(b.available, True) self.assertEqual(len(b.disks), 2) self.assertEqual(b.disks[0].size, 1024) @@ -226,6 +227,100 @@ def test_mutate(self): with self.mock_post(result) as m: linode.mutate() self.assertEqual(m.call_url, '/linode/instances/123/mutate') + self.assertEqual(m.call_data["allow_auto_disk_resize"], True) + + def test_firewalls(self): + """ + Tests that you can submit a correct firewalls api request + """ + linode = Instance(self.client, 123) + + with self.mock_get('/linode/instances/123/firewalls') as m: + result = linode.firewalls() + self.assertEqual(m.call_url, '/linode/instances/123/firewalls') + self.assertEquals(len(result), 1) + + def test_volumes(self): + """ + Tests that you can submit a correct volumes api request + """ + linode = Instance(self.client, 123) + + with self.mock_get('/linode/instances/123/volumes') as m: + result = linode.volumes() + self.assertEqual(m.call_url, '/linode/instances/123/volumes') + self.assertEquals(len(result), 1) + + def test_nodebalancers(self): + """ + Tests that you can submit a correct nodebalancers api request + """ + linode = Instance(self.client, 123) + + with self.mock_get('/linode/instances/123/nodebalancers') as m: + result = linode.nodebalancers() + self.assertEqual(m.call_url, '/linode/instances/123/nodebalancers') + self.assertEquals(len(result), 1) + + def test_transfer_year_month(self): + """ + Tests that you can submit a correct transfer api request + """ + linode = Instance(self.client, 123) + + with self.mock_get('/linode/instances/123/transfer/2023/4') as m: + linode.transfer_year_month(2023, 4) + self.assertEqual(m.call_url, '/linode/instances/123/transfer/2023/4') + + def test_duplicate(self): + """ + Tests that you can submit a correct disk clone api request + """ + disk = Disk(self.client, 12345, 123) + + with self.mock_post("/linode/instances/123/disks/12345/clone") as m: + disk.duplicate() + self.assertEqual(m.call_url, '/linode/instances/123/disks/12345/clone') + + def test_disk_password(self): + """ + Tests that you can submit a correct disk password reset api request + """ + disk = Disk(self.client, 12345, 123) + + with self.mock_post({}) as m: + disk.reset_root_password() + self.assertEqual(m.call_url, '/linode/instances/123/disks/12345/password') + + def test_instance_password(self): + """ + Tests that you can submit a correct instance password reset api request + """ + instance = Instance(self.client, 123) + + with self.mock_post({}) as m: + instance.reset_instance_root_password() + self.assertEqual(m.call_url, '/linode/instances/123/password') + + def test_ips(self): + """ + Tests that you can submit a correct ips api request + """ + linode = Instance(self.client, 123) + + ips = linode.ips + + self.assertIsNotNone(ips.ipv4) + self.assertIsNotNone(ips.ipv6) + self.assertIsNotNone(ips.ipv4.public) + self.assertIsNotNone(ips.ipv4.private) + self.assertIsNotNone(ips.ipv4.shared) + self.assertIsNotNone(ips.ipv4.reserved) + self.assertIsNotNone(ips.ipv6.slaac) + self.assertIsNotNone(ips.ipv6.link_local) + self.assertIsNotNone(ips.ipv6.pools) + + def test_initiate_migration(self): """ @@ -310,6 +405,23 @@ def test_update_interfaces(self): self.assertEqual(m.call_url, '/linode/instances/123/configs/456789') self.assertEqual(m.call_data.get('interfaces'), new_interfaces) + def test_get_config(self): + json = self.client.get('/linode/instances/123/configs/456789') + config = Config(self.client, 456789, 123, json=json) + + self.assertEqual(config.root_device, "/dev/sda") + self.assertEqual(config.comments, "") + self.assertIsNotNone(config.helpers) + self.assertEqual(config.label, "My Ubuntu 17.04 LTS Profile") + self.assertEqual(config.created, datetime(year=2014, month=10, day=7, hour=20, + minute=4, second=0)) + self.assertEqual(config.memory_limit, 0) + self.assertEqual(config.id, 456789) + self.assertIsNotNone(config.interfaces) + self.assertEqual(config.run_level, "default") + self.assertIsNone(config.initrd) + self.assertEqual(config.virt_mode, "paravirt") + self.assertIsNotNone(config.devices) class TypeTest(ClientBaseCase): def test_get_types(self): From d5c854392416c120646aab4fbb55c5a1876cc82d Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Tue, 11 Apr 2023 14:46:35 -0400 Subject: [PATCH 062/379] Added missing lke-related functionality (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added missing lke-related fields and endpoints to bring lke to api-parity. ## ✔️ How to Test Run `tox`. Ticket: TPT-1888 --- linode_api4/objects/lke.py | 65 ++++++++- test/fixtures/lke_clusters_18881.json | 13 ++ .../lke_clusters_18881_dashboard.json | 3 + .../lke_clusters_18881_nodes_123456.json | 5 + .../lke_clusters_18881_pools_456.json | 27 ++++ test/objects/lke_test.py | 129 ++++++++++++++++++ 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/lke_clusters_18881.json create mode 100644 test/fixtures/lke_clusters_18881_dashboard.json create mode 100644 test/fixtures/lke_clusters_18881_nodes_123456.json create mode 100644 test/fixtures/lke_clusters_18881_pools_456.json create mode 100644 test/objects/lke_test.py diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index b25cb9af7..bdc87035a 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -53,13 +53,15 @@ class LKENodePool(DerivedBase): "disks": Property(), "count": Property(mutable=True), "nodes": Property(volatile=True), # this is formatted in _populate below + "autoscaler": Property(), + "tags": Property(mutable=True), } def _populate(self, json): """ Parse Nodes into more useful LKENodePoolNode objects """ - if json is not None: + if json != {}: new_nodes = [ LKENodePoolNode(self._client, c) for c in json["nodes"] ] @@ -92,6 +94,7 @@ class LKECluster(Base): "region": Property(slug_relationship=Region), "k8s_version": Property(slug_relationship=KubeVersion), "pools": Property(derived_class=LKENodePool), + "control_plane": Property(), } @property @@ -165,3 +168,63 @@ def node_pool_create(self, node_type, node_count, **kwargs): raise UnexpectedResponseError('Unexpected response creating node pool!', json=result) return LKENodePool(self._client, result["id"], self.id, result) + + def cluster_dashboard_url_view(self): + """ + Get a Kubernetes Dashboard access URL for this Cluster. + """ + + result = self._client.get('{}/dashboard'.format(LKECluster.api_endpoint), model=self) + + return result["url"] + + def kubeconfig_delete(self): + """ + Delete and regenerate the Kubeconfig file for a Cluster. + """ + + self._client.delete('{}/kubeconfig'.format(LKECluster.api_endpoint), model=self) + + def node_view(self, nodeId): + """ + Get a specific Node Pool by ID. + """ + + node = self._client.get('{}/nodes/{}'.format(LKECluster.api_endpoint, nodeId), model=self) + + return LKENodePoolNode(self._client, node) + + def node_delete(self, nodeId): + """ + Delete a specific Node from a Node Pool. + """ + + self._client.delete('{}/nodes/{}'.format(LKECluster.api_endpoint, nodeId), model=self) + + def node_recycle(self, nodeId): + """ + Recycle a specific Node from an LKE cluster. + """ + + self._client.post('{}/nodes/{}/recycle'.format(LKECluster.api_endpoint, nodeId), model=self) + + def cluster_nodes_recycle(self): + """ + Recycles all nodes in all pools of a designated Kubernetes Cluster. + """ + + self._client.post('{}/recycle'.format(LKECluster.api_endpoint), model=self) + + def cluster_regenerate(self): + """ + Regenerate the Kubeconfig file and/or the service account token for a Cluster. + """ + + self._client.post('{}/regenerate'.format(LKECluster.api_endpoint), model=self) + + def service_token_delete(self): + """ + Delete and regenerate the service account token for a Cluster. + """ + + self._client.delete('{}/servicetoken'.format(LKECluster.api_endpoint), model=self) \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881.json b/test/fixtures/lke_clusters_18881.json new file mode 100644 index 000000000..755d11c58 --- /dev/null +++ b/test/fixtures/lke_clusters_18881.json @@ -0,0 +1,13 @@ +{ + "id": 18881, + "status": "ready", + "created": "2021-02-10T23:54:21", + "updated": "2021-02-10T23:54:21", + "label": "example-cluster", + "region": "ap-west", + "k8s_version": "1.19", + "tags": [], + "control_plane": { + "high_availability": true + } +} \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_dashboard.json b/test/fixtures/lke_clusters_18881_dashboard.json new file mode 100644 index 000000000..eb58d587d --- /dev/null +++ b/test/fixtures/lke_clusters_18881_dashboard.json @@ -0,0 +1,3 @@ +{ + "url": "https://example.dashboard.linodelke.net" +} diff --git a/test/fixtures/lke_clusters_18881_nodes_123456.json b/test/fixtures/lke_clusters_18881_nodes_123456.json new file mode 100644 index 000000000..311ef3878 --- /dev/null +++ b/test/fixtures/lke_clusters_18881_nodes_123456.json @@ -0,0 +1,5 @@ +{ + "id": "123456", + "instance_id": 123458, + "status": "ready" + } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json new file mode 100644 index 000000000..ec6b570ac --- /dev/null +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -0,0 +1,27 @@ +{ + "autoscaler": { + "enabled": true, + "max": 12, + "min": 3 + }, + "count": 6, + "disks": [ + { + "size": 1024, + "type": "ext-4" + } + ], + "id": 456, + "nodes": [ + { + "id": "123456", + "instance_id": 123458, + "status": "ready" + } + ], + "tags": [ + "example tag", + "another example" + ], + "type": "g6-standard-4" + } \ No newline at end of file diff --git a/test/objects/lke_test.py b/test/objects/lke_test.py new file mode 100644 index 000000000..a2721962e --- /dev/null +++ b/test/objects/lke_test.py @@ -0,0 +1,129 @@ +from datetime import datetime +from test.base import ClientBaseCase + +from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode + +class LKETest(ClientBaseCase): + """ + Tests methods of the LKE class + """ + + def test_get_cluster(self): + """ + Tests that the LKECluster object is properly generated. + """ + + cluster = LKECluster(self.client, 18881) + + self.assertEqual(cluster.id, 18881) + self.assertEqual(cluster.created, datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21)) + self.assertEqual(cluster.updated, datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21)) + self.assertEqual(cluster.label, "example-cluster") + self.assertEqual(cluster.tags, []) + self.assertEqual(cluster.region.id, "ap-west") + self.assertEqual(cluster.k8s_version.id, "1.19") + self.assertTrue(cluster.control_plane.high_availability) + + def test_get_pool(self): + """ + Tests that the LKENodePool object is properly generated. + """ + + pool = LKENodePool(self.client, 456, 18881) + + self.assertEqual(pool.id, 456) + self.assertEqual(pool.cluster_id, 18881) + self.assertEqual(pool.type.id, "g6-standard-4") + self.assertIsNotNone(pool.disks) + self.assertIsNotNone(pool.nodes) + self.assertIsNotNone(pool.autoscaler) + self.assertIsNotNone(pool.tags) + + def test_cluster_dashboard_url_view(self): + """ + Tests that you can submit a correct cluster dashboard url api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_get('/lke/clusters/18881/dashboard') as m: + result = cluster.cluster_dashboard_url_view() + self.assertEqual(m.call_url, '/lke/clusters/18881/dashboard') + self.assertEqual(result, "https://example.dashboard.linodelke.net") + + def test_kubeconfig_delete(self): + """ + Tests that you can submit a correct kubeconfig delete api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_delete() as m: + cluster.kubeconfig_delete() + self.assertEqual(m.call_url, '/lke/clusters/18881/kubeconfig') + + def test_node_view(self): + """ + Tests that you can submit a correct node view api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_get('/lke/clusters/18881/nodes/123456') as m: + node = cluster.node_view(123456) + self.assertEqual(m.call_url, '/lke/clusters/18881/nodes/123456') + self.assertIsNotNone(node) + self.assertEqual(node.id, "123456") + self.assertEqual(node.instance_id, 123458) + self.assertEqual(node.status, "ready") + + def test_node_delete(self): + """ + Tests that you can submit a correct node delete api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_delete() as m: + cluster.node_delete(1234) + self.assertEqual(m.call_url, '/lke/clusters/18881/nodes/1234') + + def test_node_recycle(self): + """ + Tests that you can submit a correct node recycle api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_post({}) as m: + cluster.node_recycle(1234) + self.assertEqual(m.call_url, '/lke/clusters/18881/nodes/1234/recycle') + + def test_cluster_nodes_recycle(self): + """ + Tests that you can submit a correct cluster nodes recycle api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_post({}) as m: + cluster.cluster_nodes_recycle() + self.assertEqual(m.call_url, '/lke/clusters/18881/recycle') + + def test_cluster_regenerate(self): + """ + Tests that you can submit a correct cluster regenerate api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_post({}) as m: + cluster.cluster_regenerate() + self.assertEqual(m.call_url, '/lke/clusters/18881/regenerate') + + def test_service_token_delete(self): + """ + Tests that you can submit a correct service token delete api request. + """ + cluster = LKECluster(self.client, 18881) + + with self.mock_delete() as m: + cluster.service_token_delete() + self.assertEqual(m.call_url, '/lke/clusters/18881/servicetoken') + + + + \ No newline at end of file From 383ea5239b4740303d46781465df04242e5c8aa0 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 11 Apr 2023 15:48:03 -0400 Subject: [PATCH 063/379] Drop Python 3.6 Support (#224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description The status of Python 3.6 is end of life, and may contains unfixed security issues and other bugs. https://devguide.python.org/versions/#unsupported-versions Other cloud provider's tools such as AWS and Azure dropped Python 3.6 support. https://github.com/boto/boto3/blob/e7cc8bfffa2d53379214f69959645745829b2ce3/setup.py#L40 https://github.com/Azure/azure-sdk-for-python/blob/f81be8cdf1a52fca51b4786af75492d500e4572d/sdk/compute/azure-mgmt-compute/setup.py#L78 I think it is the time for us to make an similar decision. --- setup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index ede7e8f41..474ca9e0e 100755 --- a/setup.py +++ b/setup.py @@ -65,9 +65,11 @@ def get_test_suite(): # that you indicate whether you support Python 2, Python 3 or both. 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', ], # What does your project relate to? @@ -78,7 +80,7 @@ def get_test_suite(): packages=find_packages(exclude=['contrib', 'docs', 'test', 'test.*']), # What do we need for this to run - python_requires=">=3.6", + python_requires=">=3.7", install_requires=[ "requests", From 1dcbd4db4a0caabc3d093a774f48d573e7394862 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 11 Apr 2023 16:04:11 -0400 Subject: [PATCH 064/379] Create dependabot.yml --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..ba1c6b80a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" From 1456769ce88c5fc1e963dd924bdf5fc8a1f1c7c0 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 11 Apr 2023 16:05:37 -0400 Subject: [PATCH 065/379] new: Add formatters and linting workflow (#230) This pull request adds a new `make format` target that runs the following workflows: - black - isort - autoflake Additionally, it adds a `make lint` target and workflow that validates that code is properly formatted. --- .github/workflows/lint.yml | 24 + .pylintrc | 4 +- Makefile | 17 + linode_api4/__init__.py | 1 + linode_api4/common.py | 32 +- linode_api4/errors.py | 7 +- linode_api4/linode_client.py | 624 +++++++++++++-------- linode_api4/login_client.py | 167 +++--- linode_api4/objects/__init__.py | 1 + linode_api4/objects/account.py | 214 ++++--- linode_api4/objects/base.py | 174 ++++-- linode_api4/objects/database.py | 332 ++++++----- linode_api4/objects/dbase.py | 15 +- linode_api4/objects/domain.py | 67 +-- linode_api4/objects/filtering.py | 72 ++- linode_api4/objects/image.py | 5 +- linode_api4/objects/linode.py | 770 ++++++++++++++++---------- linode_api4/objects/lke.py | 107 ++-- linode_api4/objects/longview.py | 9 +- linode_api4/objects/networking.py | 99 ++-- linode_api4/objects/nodebalancer.py | 114 ++-- linode_api4/objects/object_storage.py | 24 +- linode_api4/objects/profile.py | 78 +-- linode_api4/objects/region.py | 10 +- linode_api4/objects/support.py | 103 ++-- linode_api4/objects/tag.py | 70 ++- linode_api4/objects/volume.py | 75 ++- linode_api4/paginated_list.py | 98 +++- linode_api4/util.py | 8 +- pyproject.toml | 20 + requirements-dev.txt | 4 + test/base.py | 40 +- test/fixtures.py | 21 +- test/linode_client_test.py | 507 ++++++++++------- test/objects/account_test.py | 14 +- test/objects/database_test.py | 375 +++++++------ test/objects/firewall_test.py | 47 +- test/objects/image_test.py | 40 +- test/objects/linode_test.py | 212 ++++--- test/objects/lke_test.py | 37 +- test/objects/longview_test.py | 24 +- test/objects/nodebalancers_test.py | 58 +- test/objects/profile_test.py | 52 +- test/objects/tag_test.py | 20 +- test/objects/volume_test.py | 46 +- test/paginated_list_test.py | 42 +- test/util_test.py | 23 +- 47 files changed, 2950 insertions(+), 1953 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 pyproject.toml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..63d4fe1cd --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Linting Actions +on: + pull_request: null + push: + branches: + - master + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: checkout repo + uses: actions/checkout@v3 + + - name: setup python 3 + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: install dependencies + run: pip3 install -r requirements-dev.txt -r requirements.txt + + - name: run linter + run: make lint \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 246360d05..2084a0c5d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,13 +50,13 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call +disable=consider-using-dict-items,blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call,no-value-for-parameter,c-extension-no-member,attribute-defined-outside-init,use-a-generator # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. -enable=syntax-error,unrecognized-inline-option,init-is-generator,return-in-init,function-redefined,not-in-loop,return-outside-function,yield-outside-function,return-arg-in-generator,nonexistent-operator,duplicate-argument-name,abstract-class-instantiated,bad-reversed-sequence,too-many-star-expressions,invalid-star-assignment-target,star-needs-assignment-target,nonlocal-and-global,continue-in-finally,nonlocal-without-binding,used-prior-global-declaration,method-hidden,access-member-before-definition,no-method-argument,no-self-argument,invalid-slots-object,assigning-non-slot,invalid-slots,inherit-non-class,inconsistent-mro,duplicate-bases,non-iterator-returned,unexpected-special-method-signature,invalid-length-returned,import-error,relative-beyond-top-level,used-before-assignment,undefined-variable,undefined-all-variable,invalid-all-object,no-name-in-module,unbalanced-tuple-unpacking,unpacking-non-sequence,bad-except-order,raising-bad-type,bad-exception-context,misplaced-bare-raise,raising-non-exception,notimplemented-raised,catching-non-exception,slots-on-old-class,super-on-old-class,bad-super-call,missing-super-argument,no-member,not-callable,assignment-from-no-return,no-value-for-parameter,too-many-function-args,unexpected-keyword-arg,redundant-keyword-arg,missing-kwoa,invalid-sequence-index,invalid-slice-index,assignment-from-none,not-context-manager,invalid-unary-operand-type,unsupported-binary-operation,repeated-keyword,not-an-iterable,not-a-mapping,unsupported-membership-test,unsubscriptable-object,unsupported-assignment-operation,unsupported-delete-operation,invalid-metaclass,logging-unsupported-format,logging-format-truncated,logging-too-many-args,logging-too-few-args,bad-format-character,truncated-format-string,mixed-format-string,format-needs-mapping,missing-format-string-key,too-many-format-args,too-few-format-args,bad-str-strip-call,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,yield-inside-async-function,not-async-context-manager,unused-variable,attribute-defined-outside-init,bad-indentation +enable=syntax-error,unrecognized-inline-option,init-is-generator,return-in-init,function-redefined,not-in-loop,return-outside-function,yield-outside-function,return-arg-in-generator,nonexistent-operator,duplicate-argument-name,abstract-class-instantiated,bad-reversed-sequence,too-many-star-expressions,invalid-star-assignment-target,star-needs-assignment-target,nonlocal-and-global,continue-in-finally,nonlocal-without-binding,used-prior-global-declaration,method-hidden,access-member-before-definition,no-method-argument,no-self-argument,invalid-slots-object,assigning-non-slot,invalid-slots,inherit-non-class,inconsistent-mro,duplicate-bases,non-iterator-returned,unexpected-special-method-signature,invalid-length-returned,import-error,relative-beyond-top-level,used-before-assignment,undefined-variable,undefined-all-variable,invalid-all-object,no-name-in-module,unbalanced-tuple-unpacking,unpacking-non-sequence,bad-except-order,raising-bad-type,bad-exception-context,misplaced-bare-raise,raising-non-exception,notimplemented-raised,catching-non-exception,slots-on-old-class,super-on-old-class,bad-super-call,missing-super-argument,no-member,not-callable,assignment-from-no-return,too-many-function-args,unexpected-keyword-arg,redundant-keyword-arg,missing-kwoa,invalid-sequence-index,invalid-slice-index,assignment-from-none,not-context-manager,invalid-unary-operand-type,unsupported-binary-operation,repeated-keyword,not-an-iterable,not-a-mapping,unsupported-membership-test,unsubscriptable-object,unsupported-assignment-operation,unsupported-delete-operation,invalid-metaclass,logging-unsupported-format,logging-format-truncated,logging-too-many-args,logging-too-few-args,bad-format-character,truncated-format-string,mixed-format-string,format-needs-mapping,missing-format-string-key,too-many-format-args,too-few-format-args,bad-str-strip-call,print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,yield-inside-async-function,not-async-context-manager,unused-variable,bad-indentation [REPORTS] diff --git a/Makefile b/Makefile index ca657d7aa..b64c31449 100644 --- a/Makefile +++ b/Makefile @@ -14,3 +14,20 @@ build: clean @PHONEY: release release: build twine upload dist/* + +black: + black linode_api4 test + +isort: + isort linode_api4 test + +autoflake: + autoflake linode_api4 test + +format: black isort autoflake + +lint: + isort --check-only linode_api4 test + autoflake --check linode_api4 test + black --check --verbose linode_api4 test + pylint linode_api4 \ No newline at end of file diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py index f9266732f..bd1d6023a 100644 --- a/linode_api4/__init__.py +++ b/linode_api4/__init__.py @@ -1,3 +1,4 @@ +# isort: skip_file from linode_api4.objects import * from linode_api4.errors import ApiError, UnexpectedResponseError from linode_api4.linode_client import LinodeClient diff --git a/linode_api4/common.py b/linode_api4/common.py index aacdd55c3..df3da9733 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -1,8 +1,13 @@ import os - -SSH_KEY_TYPES = ("ssh-dss", "ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", - "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521") +SSH_KEY_TYPES = ( + "ssh-dss", + "ssh-rsa", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp521", +) def load_and_validate_keys(authorized_keys): @@ -26,8 +31,15 @@ def load_and_validate_keys(authorized_keys): ret = [] for k in authorized_keys: - accepted_types = ('ssh-dss', 'ssh-rsa', 'ecdsa-sha2-nistp', 'ssh-ed25519') - if any([ t for t in accepted_types if k.startswith(t) ]): # pylint: disable=use-a-generator + accepted_types = ( + "ssh-dss", + "ssh-rsa", + "ecdsa-sha2-nistp", + "ssh-ed25519", + ) + if any( + [t for t in accepted_types if k.startswith(t)] + ): # pylint: disable=use-a-generator # this looks like a key, cool ret.append(k) else: @@ -37,7 +49,11 @@ def load_and_validate_keys(authorized_keys): with open(k) as f: ret.append(f.read().rstrip()) else: - raise ValueError("authorized_keys must either be paths " - "to the key files or a list of raw " - "public key of one of these types: {}".format(accepted_types)) + raise ValueError( + "authorized_keys must either be paths " + "to the key files or a list of raw " + "public key of one of these types: {}".format( + accepted_types + ) + ) return ret diff --git a/linode_api4/errors.py b/linode_api4/errors.py index 54df28dce..bc2df6108 100644 --- a/linode_api4/errors.py +++ b/linode_api4/errors.py @@ -7,13 +7,15 @@ class ApiError(RuntimeError): typically have a status code in the 400s or 500s. Most often, this will be caused by invalid input to the API. """ + def __init__(self, message, status=400, json=None): super().__init__(message) self.status = status self.json = json self.errors = [] - if json and 'errors' in json and isinstance(json['errors'], list): - self.errors = [ e['reason'] for e in json['errors'] ] + if json and "errors" in json and isinstance(json["errors"], list): + self.errors = [e["reason"] for e in json["errors"]] + class UnexpectedResponseError(RuntimeError): """ @@ -23,6 +25,7 @@ class UnexpectedResponseError(RuntimeError): These typically indicate an oversight in developing this library, and should be fixed with changes to this codebase. """ + def __init__(self, message, status=200, json=None): super().__init__(message) self.status = status diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index d7b987c71..208f1d1d9 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -5,7 +5,7 @@ import os import time from datetime import datetime -from typing import Tuple, BinaryIO +from typing import BinaryIO, Tuple import pkg_resources import requests @@ -39,6 +39,7 @@ class LinodeGroup(Group): This group contains all features beneath the `/linode` group in the API v4. """ + def types(self, *filters): """ Returns a list of Linode Instance types. These may be used to create @@ -85,19 +86,23 @@ def stackscripts(self, *filters, **kwargs): :rtype: PaginatedList of StackScript """ # python2 can't handle *args and a single keyword argument, so this is a workaround - if 'mine_only' in kwargs: - if kwargs['mine_only']: - new_filter = Filter({"mine":True}) + if "mine_only" in kwargs: + if kwargs["mine_only"]: + new_filter = Filter({"mine": True}) if filters: filters = list(filters) filters[0] = filters[0] & new_filter else: filters = [new_filter] - del kwargs['mine_only'] + del kwargs["mine_only"] if kwargs: - raise TypeError("stackscripts() got unexpected keyword argument '{}'".format(kwargs.popitem()[0])) + raise TypeError( + "stackscripts() got unexpected keyword argument '{}'".format( + kwargs.popitem()[0] + ) + ) return self.client._get_and_filter(StackScript, *filters) @@ -114,8 +119,9 @@ def kernels(self, *filters): return self.client._get_and_filter(Kernel, *filters) # create things - def instance_create(self, ltype, region, image=None, - authorized_keys=None, **kwargs): + def instance_create( + self, ltype, region, image=None, authorized_keys=None, **kwargs + ): """ Creates a new Linode Instance. This function has several modes of operation: @@ -240,43 +246,55 @@ def instance_create(self, ltype, region, image=None, an outdated library. """ ret_pass = None - if image and not 'root_pass' in kwargs: + if image and not "root_pass" in kwargs: ret_pass = Instance.generate_root_password() - kwargs['root_pass'] = ret_pass + kwargs["root_pass"] = ret_pass authorized_keys = load_and_validate_keys(authorized_keys) if "stackscript" in kwargs: # translate stackscripts - kwargs["stackscript_id"] = (kwargs["stackscript"].id if issubclass(type(kwargs["stackscript"]), Base) - else kwargs["stackscript"]) + kwargs["stackscript_id"] = ( + kwargs["stackscript"].id + if issubclass(type(kwargs["stackscript"]), Base) + else kwargs["stackscript"] + ) del kwargs["stackscript"] if "backup" in kwargs: # translate backups - kwargs["backup_id"] = (kwargs["backup"].id if issubclass(type(kwargs["backup"]), Base) - else kwargs["backup"]) + kwargs["backup_id"] = ( + kwargs["backup"].id + if issubclass(type(kwargs["backup"]), Base) + else kwargs["backup"] + ) del kwargs["backup"] params = { - 'type': ltype.id if issubclass(type(ltype), Base) else ltype, - 'region': region.id if issubclass(type(region), Base) else region, - 'image': (image.id if issubclass(type(image), Base) else image) if image else None, - 'authorized_keys': authorized_keys, - } + "type": ltype.id if issubclass(type(ltype), Base) else ltype, + "region": region.id if issubclass(type(region), Base) else region, + "image": (image.id if issubclass(type(image), Base) else image) + if image + else None, + "authorized_keys": authorized_keys, + } params.update(kwargs) - result = self.client.post('/linode/instances', data=params) + result = self.client.post("/linode/instances", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating linode!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating linode!", json=result + ) - l = Instance(self.client, result['id'], result) + l = Instance(self.client, result["id"], result) if not ret_pass: return l return l, ret_pass - def stackscript_create(self, label, script, images, desc=None, public=False, **kwargs): + def stackscript_create( + self, label, script, images, desc=None, public=False, **kwargs + ): """ Creates a new :any:`StackScript` on your account. @@ -301,13 +319,17 @@ def stackscript_create(self, label, script, images, desc=None, public=False, **k """ image_list = None if type(images) is list or type(images) is PaginatedList: - image_list = [d.id if issubclass(type(d), Base) else d for d in images ] + image_list = [ + d.id if issubclass(type(d), Base) else d for d in images + ] elif type(images) is Image: image_list = [images.id] elif type(images) is str: image_list = [images] else: - raise ValueError('images must be a list of Images or a single Image') + raise ValueError( + "images must be a list of Images or a single Image" + ) script_body = script if not script.startswith("#!"): @@ -316,23 +338,27 @@ def stackscript_create(self, label, script, images, desc=None, public=False, **k with open(script) as f: script_body = f.read() else: - raise ValueError("script must be the script text or a path to a file") + raise ValueError( + "script must be the script text or a path to a file" + ) params = { "label": label, "images": image_list, "is_public": public, "script": script_body, - "description": desc if desc else '', + "description": desc if desc else "", } params.update(kwargs) - result = self.client.post('/linode/stackscripts', data=params) + result = self.client.post("/linode/stackscripts", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating StackScript!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating StackScript!", json=result + ) - s = StackScript(self.client, result['id'], result) + s = StackScript(self.client, result["id"], result) return s @@ -340,6 +366,7 @@ class ProfileGroup(Group): """ Collections related to your user. """ + def __call__(self): """ Retrieve the acting user's Profile, containing information about the @@ -351,12 +378,14 @@ def __call__(self): :returns: The acting user's profile. :rtype: Profile """ - result = self.client.get('/profile') + result = self.client.get("/profile") - if not 'username' in result: - raise UnexpectedResponseError('Unexpected response when getting profile!', json=result) + if not "username" in result: + raise UnexpectedResponseError( + "Unexpected response when getting profile!", json=result + ) - p = Profile(self.client, result['username'], result) + p = Profile(self.client, result["username"], result) return p def tokens(self, *filters): @@ -370,21 +399,23 @@ def token_create(self, label=None, expiry=None, scopes=None, **kwargs): Creates and returns a new Personal Access Token """ if label: - kwargs['label'] = label + kwargs["label"] = label if expiry: if isinstance(expiry, datetime): expiry = datetime.strftime(expiry, "%Y-%m-%dT%H:%M:%S") - kwargs['expiry'] = expiry + kwargs["expiry"] = expiry if scopes: - kwargs['scopes'] = scopes + kwargs["scopes"] = scopes - result = self.client.post('/profile/tokens', data=kwargs) + result = self.client.post("/profile/tokens", data=kwargs) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating Personal Access ' - 'Token!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Personal Access Token!", + json=result, + ) - token = PersonalAccessToken(self.client, result['id'], result) + token = PersonalAccessToken(self.client, result["id"], result) return token def apps(self, *filters): @@ -423,20 +454,21 @@ def ssh_key_upload(self, key, label): with open(path) as f: key = f.read().strip() if not key.startswith(SSH_KEY_TYPES): - raise ValueError('Invalid SSH Public Key') + raise ValueError("Invalid SSH Public Key") params = { - 'ssh_key': key, - 'label': label, + "ssh_key": key, + "label": label, } - result = self.client.post('/profile/sshkeys', data=params) + result = self.client.post("/profile/sshkeys", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when uploading SSH Key!', - json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when uploading SSH Key!", json=result + ) - ssh_key = SSHKey(self.client, result['id'], result) + ssh_key = SSHKey(self.client, result["id"], result) return ssh_key @@ -451,6 +483,7 @@ class LKEGroup(Group): This group contains all features beneath the `/lke` group in the API v4. """ + def versions(self, *filters): """ Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be @@ -537,12 +570,14 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): } params.update(kwargs) - result = self.client.post('/lke/clusters', data=params) + result = self.client.post("/lke/clusters", data=params) - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating LKE cluster!', json=result) + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating LKE cluster!", json=result + ) - return LKECluster(self.client, result['id'], result) + return LKECluster(self.client, result["id"], result) def node_pool(self, node_type, node_count): """ @@ -563,6 +598,7 @@ def node_pool(self, node_type, node_count): "count": node_count, } + class LongviewGroup(Group): def clients(self, *filters): """ @@ -584,15 +620,15 @@ def client_create(self, label=None): :raises UnexpectedResponseError: If the returned data from the api does not look as expected. """ - result = self.client.post('/longview/clients', data={ - "label": label - }) + result = self.client.post("/longview/clients", data={"label": label}) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating Longivew ' - 'Client!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Longview Client!", + json=result, + ) - c = LongviewClient(self.client, result['id'], result) + c = LongviewClient(self.client, result["id"], result) return c def subscriptions(self, *filters): @@ -614,13 +650,14 @@ def __call__(self): :returns: Returns the acting user's account information. :rtype: Account """ - result = self.client.get('/account') + result = self.client.get("/account") - if not 'email' in result: - raise UnexpectedResponseError('Unexpected response when getting account!', json=result) - - return Account(self.client, result['email'], result) + if not "email" in result: + raise UnexpectedResponseError( + "Unexpected response when getting account!", json=result + ) + return Account(self.client, result["email"], result) def events(self, *filters): return self.client._get_and_filter(Event, *filters) @@ -631,20 +668,25 @@ def events_mark_seen(self, event): as an event_id, otherwise it should be an event object whose id will be used. """ last_seen = event if isinstance(event, int) else event.id - self.client.post('{}/seen'.format(Event.api_endpoint), model=Event(self.client, last_seen)) + self.client.post( + "{}/seen".format(Event.api_endpoint), + model=Event(self.client, last_seen), + ) def settings(self): """ Resturns the account settings data for this acocunt. This is not a listing endpoint. """ - result = self.client.get('/account/settings') + result = self.client.get("/account/settings") - if not 'managed' in result: - raise UnexpectedResponseError('Unexpected response when getting account settings!', - json=result) + if not "managed" in result: + raise UnexpectedResponseError( + "Unexpected response when getting account settings!", + json=result, + ) - s = AccountSettings(self.client, result['managed'], result) + s = AccountSettings(self.client, result["managed"], result) return s def invoices(self): @@ -675,13 +717,14 @@ def oauth_client_create(self, name, redirect_uri, **kwargs): } params.update(kwargs) - result = self.client.post('/account/oauth-clients', data=params) + result = self.client.post("/account/oauth-clients", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating OAuth Client!', - json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating OAuth Client!", json=result + ) - c = OAuthClient(self.client, result['id'], result) + c = OAuthClient(self.client, result["id"], result) return c def users(self, *filters): @@ -694,10 +737,12 @@ def transfer(self): """ Returns a MappedObject containing the account's transfer pool data """ - result = self.client.get('/account/transfer') + result = self.client.get("/account/transfer") - if not 'used' in result: - raise UnexpectedResponseError('Unexpected response when getting Transfer Pool!') + if not "used" in result: + raise UnexpectedResponseError( + "Unexpected response when getting Transfer Pool!" + ) return MappedObject(**result) @@ -732,14 +777,19 @@ def user_create(self, email, username, restricted=True): "username": username, "restricted": restricted, } - result = self.client.post('/account/users', data=params) + result = self.client.post("/account/users", data=params) - if not all([c in result for c in ('email', 'restricted', 'username')]): # pylint: disable=use-a-generator - raise UnexpectedResponseError('Unexpected response when creating user!', json=result) + if not all( + [c in result for c in ("email", "restricted", "username")] + ): # pylint: disable=use-a-generator + raise UnexpectedResponseError( + "Unexpected response when creating user!", json=result + ) - u = User(self.client, result['username'], result) + u = User(self.client, result["username"], result) return u + class NetworkingGroup(Group): def firewalls(self, *filters): """ @@ -800,17 +850,19 @@ def firewall_create(self, label, rules, **kwargs): """ params = { - 'label': label, - 'rules': rules, + "label": label, + "rules": rules, } params.update(kwargs) - result = self.client.post('/networking/firewalls', data=params) + result = self.client.post("/networking/firewalls", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating Firewall!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Firewall!", json=result + ) - f = Firewall(self.client, result['id'], result) + f = Firewall(self.client, result["id"], result) return f def ips(self, *filters): @@ -826,7 +878,7 @@ def vlans(self, *filters): """ .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. Returns a list of VLANs on your account. - + :returns: A Paginated List of VLANs on your account. :rtype: PaginatedList of VLAN """ @@ -871,15 +923,18 @@ def ips_assign(self, region, *assignments): :type assignments: dct """ for a in assignments: - if not 'address' in a or not 'linode_id' in a: + if not "address" in a or not "linode_id" in a: raise ValueError("Invalid assignment: {}".format(a)) if isinstance(region, Region): region = region.id - self.client.post('/networking/ipv4/assign', data={ - "region": region, - "assignments": assignments, - }) + self.client.post( + "/networking/ipv4/assign", + data={ + "region": region, + "assignments": assignments, + }, + ) def ip_allocate(self, linode, public=True): """ @@ -894,17 +949,21 @@ def ip_allocate(self, linode, public=True): :returns: The new IPAddress :rtype: IPAddress """ - result = self.client.post('/networking/ips/', data={ - "linode_id": linode.id if isinstance(linode, Base) else linode, - "type": "ipv4", - "public": public, - }) + result = self.client.post( + "/networking/ips/", + data={ + "linode_id": linode.id if isinstance(linode, Base) else linode, + "type": "ipv4", + "public": public, + }, + ) - if not 'address' in result: - raise UnexpectedResponseError('Unexpected response when adding IPv4 address!', - json=result) + if not "address" in result: + raise UnexpectedResponseError( + "Unexpected response when adding IPv4 address!", json=result + ) - ip = IPAddress(self.client, result['address'], result) + ip = IPAddress(self.client, result["address"], result) return ip def ips_share(self, linode, *ips): @@ -930,16 +989,17 @@ def ips_share(self, linode, *ips): elif isinstance(ip, IPAddress): params.append(ip.address) else: - params.append(str(ip)) # and hope that works + params.append(str(ip)) # and hope that works - params = { - "ips": params - } + params = {"ips": params} - self.client.post('{}/networking/ipv4/share'.format(Instance.api_endpoint), - model=linode, data=params) + self.client.post( + "{}/networking/ipv4/share".format(Instance.api_endpoint), + model=linode, + data=params, + ) - linode.invalidate() # clear the Instance's shared IPs + linode.invalidate() # clear the Instance's shared IPs class SupportGroup(Group): @@ -947,9 +1007,7 @@ def tickets(self, *filters): return self.client._get_and_filter(SupportTicket, *filters) def ticket_open(self, summary, description, regarding=None): - """ - - """ + """ """ params = { "summary": summary, "description": description, @@ -957,24 +1015,28 @@ def ticket_open(self, summary, description, regarding=None): if regarding: if isinstance(regarding, Instance): - params['linode_id'] = regarding.id + params["linode_id"] = regarding.id elif isinstance(regarding, Domain): - params['domain_id'] = regarding.id + params["domain_id"] = regarding.id elif isinstance(regarding, NodeBalancer): - params['nodebalancer_id'] = regarding.id + params["nodebalancer_id"] = regarding.id elif isinstance(regarding, Volume): - params['volume_id'] = regarding.id + params["volume_id"] = regarding.id else: - raise ValueError('Cannot open ticket regarding type {}!'.format(type(regarding))) - + raise ValueError( + "Cannot open ticket regarding type {}!".format( + type(regarding) + ) + ) - result = self.client.post('/support/tickets', data=params) + result = self.client.post("/support/tickets", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating ticket!', - json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating ticket!", json=result + ) - t = SupportTicket(self.client, result['id'], result) + t = SupportTicket(self.client, result["id"], result) return t @@ -983,6 +1045,7 @@ class ObjectStorageGroup(Group): This group encapsulates all endpoints under /object-storage, including viewing available clusters and managing keys. """ + def clusters(self, *filters): """ Returns a list of available Object Storage Clusters. You may filter @@ -1058,9 +1121,7 @@ def keys_create(self, label, bucket_access=None): :returns: The new keypair, with the secret key populated. :rtype: ObjectStorageKeys """ - params = { - "label": label - } + params = {"label": label} if bucket_access is not None: if not isinstance(bucket_access, list): @@ -1070,18 +1131,24 @@ def keys_create(self, label, bucket_access=None): { "permissions": c.get("permissions"), "bucket_name": c.get("bucket_name"), - "cluster": c.id if "cluster" in c and issubclass(type(c["cluster"]), Base) else c.get("cluster"), - } for c in bucket_access + "cluster": c.id + if "cluster" in c and issubclass(type(c["cluster"]), Base) + else c.get("cluster"), + } + for c in bucket_access ] - params['bucket_access'] = ba + params["bucket_access"] = ba - result = self.client.post('/object-storage/keys', data=params) + result = self.client.post("/object-storage/keys", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating Object Storage Keys!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Object Storage Keys!", + json=result, + ) - ret = ObjectStorageKeys(self.client, result['id'], result) + ret = ObjectStorageKeys(self.client, result["id"], result) return ret def bucket_access(self, cluster, bucket_name, permissions): @@ -1114,7 +1181,7 @@ def cancel(self): cancelled, you will no longer receive the transfer for or be billed for Object Storage, and all keys will be invalidated. """ - self.client.post('/object-storage/cancel', data={}) + self.client.post("/object-storage/cancel", data={}) return True @@ -1215,19 +1282,21 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): """ params = { - 'label': label, - 'region': region.id if issubclass(type(region), Base) else region, - 'engine': engine.id if issubclass(type(engine), Base) else engine, - 'type': ltype.id if issubclass(type(ltype), Base) else ltype, + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "engine": engine.id if issubclass(type(engine), Base) else engine, + "type": ltype.id if issubclass(type(ltype), Base) else ltype, } params.update(kwargs) - result = self.client.post('/databases/mysql/instances', data=params) + result = self.client.post("/databases/mysql/instances", data=params) - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating MySQL Database', json=result) + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating MySQL Database", json=result + ) - d = MySQLDatabase(self.client, result['id'], result) + d = MySQLDatabase(self.client, result["id"], result) return d def postgresql_instances(self, *filters): @@ -1272,19 +1341,24 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): """ params = { - 'label': label, - 'region': region.id if issubclass(type(region), Base) else region, - 'engine': engine.id if issubclass(type(engine), Base) else engine, - 'type': ltype.id if issubclass(type(ltype), Base) else ltype, + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "engine": engine.id if issubclass(type(engine), Base) else engine, + "type": ltype.id if issubclass(type(ltype), Base) else ltype, } params.update(kwargs) - result = self.client.post('/databases/postgresql/instances', data=params) + result = self.client.post( + "/databases/postgresql/instances", data=params + ) - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating PostgreSQL Database', json=result) + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating PostgreSQL Database", + json=result, + ) - d = PostgreSQLDatabase(self.client, result['id'], result) + d = PostgreSQLDatabase(self.client, result["id"], result) return d def mongodb_instances(self, *filters): @@ -1329,24 +1403,34 @@ def mongodb_create(self, label, region, engine, ltype, **kwargs): """ params = { - 'label': label, - 'region': region.id if issubclass(type(region), Base) else region, - 'engine': engine.id if issubclass(type(engine), Base) else engine, - 'type': ltype.id if issubclass(type(ltype), Base) else ltype, + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "engine": engine.id if issubclass(type(engine), Base) else engine, + "type": ltype.id if issubclass(type(ltype), Base) else ltype, } params.update(kwargs) - result = self.client.post('/databases/mongodb/instances', data=params) + result = self.client.post("/databases/mongodb/instances", data=params) - if 'id' not in result: - raise UnexpectedResponseError('Unexpected response when creating MongoDB Database', json=result) + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating MongoDB Database", + json=result, + ) - d = MongoDBDatabase(self.client, result['id'], result) + d = MongoDBDatabase(self.client, result["id"], result) return d class LinodeClient: - def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, page_size=None, retry_rate_limit_interval=None): + def __init__( + self, + token, + base_url="https://api.linode.com/v4", + user_agent=None, + page_size=None, + retry_rate_limit_interval=None, + ): """ The main interface to the Linode API. @@ -1384,7 +1468,9 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, if not isinstance(self.retry_rate_limit_interval, int): raise ValueError("retry_rate_limit_interval must be an int") if self.retry_rate_limit_interval < 1: - raise ValueError("retry_rate_limit_interval must not be less than 1") + raise ValueError( + "retry_rate_limit_interval must not be less than 1" + ) #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information @@ -1422,10 +1508,10 @@ def __init__(self, token, base_url="https://api.linode.com/v4", user_agent=None, @property def _user_agent(self): - return '{}python-linode_api4/{} {}'.format( - '{} '.format(self._add_user_agent) if self._add_user_agent else '', - package_version, - requests.utils.default_user_agent() + return "{}python-linode_api4/{} {}".format( + "{} ".format(self._add_user_agent) if self._add_user_agent else "", + package_version, + requests.utils.default_user_agent(), ) def load(self, target_type, target_id, target_parent_id=None): @@ -1456,12 +1542,16 @@ def load(self, target_type, target_id, target_parent_id=None): :rtype: target_type :raise ApiError: if the requested object could not be loaded. """ - result = target_type.make_instance(target_id, self, parent_id=target_parent_id) + result = target_type.make_instance( + target_id, self, parent_id=target_parent_id + ) result._api_get() return result - def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): + def _api_call( + self, endpoint, model=None, method=None, data=None, filters=None + ): """ Makes a call to the linode api. Data should only be given if the method is POST or PUT, and should be a dictionary @@ -1474,15 +1564,15 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): if model: endpoint = endpoint.format(**vars(model)) - url = '{}{}'.format(self.base_url, endpoint) + url = "{}{}".format(self.base_url, endpoint) headers = { - 'Authorization': "Bearer {}".format(self.token), - 'Content-Type': 'application/json', - 'User-Agent': self._user_agent, + "Authorization": "Bearer {}".format(self.token), + "Content-Type": "application/json", + "User-Agent": self._user_agent, } if filters: - headers['X-Filter'] = json.dumps(filters) + headers["X-Filter"] = json.dumps(filters) body = None if data is not None: @@ -1493,28 +1583,37 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): for attempt in range(max_retries): response = method(url, headers=headers, data=body) - warning = response.headers.get('Warning', None) + warning = response.headers.get("Warning", None) if warning: - logger.warning('Received warning from server: {}'.format(warning)) + logger.warning( + "Received warning from server: {}".format(warning) + ) # if we were configured to retry 429s, and we got a 429, sleep briefly and then retry if self.retry_rate_limit_interval and response.status_code == 429: - logger.warning("Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format( - self.retry_rate_limit_interval, attempt, max_retries, - )) + logger.warning( + "Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format( + self.retry_rate_limit_interval, + attempt, + max_retries, + ) + ) time.sleep(self.retry_rate_limit_interval) else: break if 399 < response.status_code < 600: j = None - error_msg = '{}: '.format(response.status_code) + error_msg = "{}: ".format(response.status_code) try: j = response.json() - if 'errors' in j.keys(): - for e in j['errors']: - error_msg += '{}; '.format(e['reason']) \ - if 'reason' in e.keys() else '' + if "errors" in j.keys(): + for e in j["errors"]: + error_msg += ( + "{}; ".format(e["reason"]) + if "reason" in e.keys() + else "" + ) except: pass raise ApiError(error_msg, status=response.status_code, json=j) @@ -1522,11 +1621,13 @@ def _api_call(self, endpoint, model=None, method=None, data=None, filters=None): if response.status_code != 204: j = response.json() else: - j = None # handle no response body + j = None # handle no response body return j - def _get_objects(self, endpoint, cls, model=None, parent_id=None, filters=None): + def _get_objects( + self, endpoint, cls, model=None, parent_id=None, filters=None + ): # handle non-default page sizes call_endpoint = endpoint if self.page_size is not None: @@ -1535,17 +1636,25 @@ def _get_objects(self, endpoint, cls, model=None, parent_id=None, filters=None): response_json = self.get(call_endpoint, model=model, filters=filters) if not "data" in response_json: - raise UnexpectedResponseError("Problem with response!", json=response_json) + raise UnexpectedResponseError( + "Problem with response!", json=response_json + ) - if 'pages' in response_json: + if "pages" in response_json: formatted_endpoint = endpoint if model: formatted_endpoint = formatted_endpoint.format(**vars(model)) - return PaginatedList.make_paginated_list(response_json, self, cls, - parent_id=parent_id, page_url=formatted_endpoint[1:], - filters=filters) - return PaginatedList.make_list(response_json["data"], self, cls, - parent_id=parent_id) + return PaginatedList.make_paginated_list( + response_json, + self, + cls, + parent_id=parent_id, + page_url=formatted_endpoint[1:], + filters=filters, + ) + return PaginatedList.make_list( + response_json["data"], self, cls, parent_id=parent_id + ) def get(self, *args, **kwargs): return self._api_call(*args, method=self.session.get, **kwargs) @@ -1612,15 +1721,20 @@ def image_create(self, disk, label=None, description=None): if description is not None: params["description"] = description - result = self.post('/images', data=params) + result = self.post("/images", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating an ' - 'Image from disk {}'.format(disk)) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating an Image from disk {}".format( + disk + ) + ) - return Image(self, result['id'], result) + return Image(self, result["id"], result) - def image_create_upload(self, label: str, region: str, description: str=None) -> Tuple[Image, str]: + def image_create_upload( + self, label: str, region: str, description: str = None + ) -> Tuple[Image, str]: """ Creates a new Image and returns the corresponding upload URL. https://www.linode.com/docs/api/images/#image-upload @@ -1635,24 +1749,23 @@ def image_create_upload(self, label: str, region: str, description: str=None) -> :returns: A tuple containing the new image and the image upload URL. :rtype: (Image, str) """ - params = { - "label": label, - "region": region, - "description": description - } + params = {"label": label, "region": region, "description": description} result = self.post("/images/upload", data=drop_null_keys(params)) if "image" not in result: - raise UnexpectedResponseError('Unexpected response when creating an ' - 'Image upload URL') + raise UnexpectedResponseError( + "Unexpected response when creating an Image upload URL" + ) result_image = result["image"] result_url = result["upload_to"] return Image(self, result_image["id"], result_image), result_url - def image_upload(self, label: str, region: str, file: BinaryIO, description: str=None) -> Image: + def image_upload( + self, label: str, region: str, file: BinaryIO, description: str = None + ) -> Image: """ Creates and uploads a new image. https://www.linode.com/docs/api/images/#image-upload @@ -1669,7 +1782,9 @@ def image_upload(self, label: str, region: str, file: BinaryIO, description: str :rtype: Image """ - image, url = self.image_create_upload(label, region, description=description) + image, url = self.image_create_upload( + label, region, description=description + ) requests.put( url, @@ -1718,12 +1833,14 @@ def nodebalancer_create(self, region, **kwargs): } params.update(kwargs) - result = self.post('/nodebalancers', data=params) + result = self.post("/nodebalancers", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating Nodebalaner!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Nodebalaner!", json=result + ) - n = NodeBalancer(self, result['id'], result) + n = NodeBalancer(self, result["id"], result) return n def domain_create(self, domain, master=True, **kwargs): @@ -1745,17 +1862,19 @@ def domain_create(self, domain, master=True, **kwargs): :rtype: Domain """ params = { - 'domain': domain, - 'type': 'master' if master else 'slave', + "domain": domain, + "type": "master" if master else "slave", } params.update(kwargs) - result = self.post('/domains', data=params) + result = self.post("/domains", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating Domain!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Domain!", json=result + ) - d = Domain(self, result['id'], result) + d = Domain(self, result["id"], result) return d def tags(self, *filters): @@ -1770,8 +1889,15 @@ def tags(self, *filters): """ return self._get_and_filter(Tag, *filters) - def tag_create(self, label, instances=None, domains=None, nodebalancers=None, - volumes=None, entities=[]): + def tag_create( + self, + label, + instances=None, + domains=None, + nodebalancers=None, + volumes=None, + entities=[], + ): """ Creates a new Tag and optionally applies it to the given entities. @@ -1804,8 +1930,10 @@ def tag_create(self, label, instances=None, domains=None, nodebalancers=None, linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], [] # filter input into lists of ids - sorter = zip((linode_ids, nodebalancer_ids, domain_ids, volume_ids), - (instances, nodebalancers, domains, volumes)) + sorter = zip( + (linode_ids, nodebalancer_ids, domain_ids, volume_ids), + (instances, nodebalancers, domains, volumes), + ) for id_list, input_list in sorter: # if we got something, we need to find its ID @@ -1828,23 +1956,25 @@ def tag_create(self, label, instances=None, domains=None, nodebalancers=None, if type(e) in type_map: type_map[type(e)].append(e.id) else: - raise ValueError('Unsupported entity type {}'.format(type(e))) + raise ValueError("Unsupported entity type {}".format(type(e))) # finally, omit all id lists that are empty params = { - 'label': label, - 'linodes': linode_ids or None, - 'nodebalancers': nodebalancer_ids or None, - 'domains': domain_ids or None, - 'volumes': volume_ids or None, + "label": label, + "linodes": linode_ids or None, + "nodebalancers": nodebalancer_ids or None, + "domains": domain_ids or None, + "volumes": volume_ids or None, } - result = self.post('/tags', data=params) + result = self.post("/tags", data=params) - if not 'label' in result: - raise UnexpectedResponseError('Unexpected response when creating Tag!', json=result) + if not "label" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Tag!", json=result + ) - t = Tag(self, result['label'], result) + t = Tag(self, result["label"], result) return t def volumes(self, *filters): @@ -1882,31 +2012,39 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): :rtype: Volume """ if not (region or linode): - raise ValueError('region or linode required!') + raise ValueError("region or linode required!") params = { "label": label, "size": size, "region": region.id if issubclass(type(region), Base) else region, - "linode_id": linode.id if issubclass(type(linode), Base) else linode, + "linode_id": linode.id + if issubclass(type(linode), Base) + else linode, } params.update(kwargs) - result = self.post('/volumes', data=params) + result = self.post("/volumes", data=params) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating volume!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating volume!", json=result + ) - v = Volume(self, result['id'], result) + v = Volume(self, result["id"], result) return v # helper functions def _get_and_filter(self, obj_type, *filters): parsed_filters = None if filters: - if(len(filters) > 1): - parsed_filters = and_(*filters).dct # pylint: disable=no-value-for-parameter + if len(filters) > 1: + parsed_filters = and_( + *filters + ).dct # pylint: disable=no-value-for-parameter else: parsed_filters = filters[0].dct - return self._get_objects(obj_type.api_list(), obj_type, filters=parsed_filters) + return self._get_objects( + obj_type.api_list(), obj_type, filters=parsed_filters + ) diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 39fd1d604..68de22f5f 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -6,17 +6,17 @@ from linode_api4.errors import ApiError try: - from urllib.parse import urlparse - from urllib.parse import urlencode - from urllib.parse import urlunparse + from urllib.parse import urlencode, urlparse, urlunparse except ImportError: - from urlparse import urlparse from urllib import urlencode - from urlparse import urlunparse -class AllWrapper(): + from urlparse import urlparse, urlunparse + + +class AllWrapper: def __repr__(self): - return '*' + return "*" + class OAuthScopes: """ @@ -44,12 +44,13 @@ class Linodes(Enum): """ Access to Linodes """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "linodes:*" return "linodes:{}".format(self.name) @@ -57,12 +58,13 @@ class Domains(Enum): """ Access to Domains """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "domains:*" return "domains:{}".format(self.name) @@ -70,12 +72,13 @@ class StackScripts(Enum): """ Access to private StackScripts """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "stackscripts:*" return "stackscripts:{}".format(self.name) @@ -85,7 +88,7 @@ class Users(Enum): all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "users:*" return "users:{}".format(self.name) @@ -93,12 +96,13 @@ class NodeBalancers(Enum): """ Access to NodeBalancers """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "nodebalancers:*" return "nodebalancers:{}".format(self.name) @@ -108,7 +112,7 @@ class Tokens(Enum): all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "tokens:*" return "tokens:{}".format(self.name) @@ -116,12 +120,13 @@ class IPs(Enum): """ Access to IPs and networking managements """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "ips:*" return "ips:{}".format(self.name) @@ -129,12 +134,13 @@ class Firewalls(Enum): """ Access to Firewalls """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "firewall:*" return "firewall:{}".format(self.name) @@ -142,12 +148,13 @@ class Tickets(Enum): """ Access to view, open, and respond to Support Tickets """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "tickets:*" return "tickets:{}".format(self.name) @@ -157,7 +164,7 @@ class Clients(Enum): all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "clients:*" return "clients:{}".format(self.name) @@ -166,12 +173,13 @@ class Account(Enum): Access to the user's account, including billing information, tokens management, user management, etc. """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "account:*" return "account:{}".format(self.name) @@ -179,12 +187,13 @@ class Events(Enum): """ Access to a user's Events """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "events:*" return "events:{}".format(self.name) @@ -192,12 +201,13 @@ class Volumes(Enum): """ Access to Block Storage Volumes """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "volumes:*" return "volumes:{}".format(self.name) @@ -205,12 +215,13 @@ class LKE(Enum): """ Access to LKE Endpoint """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "lke:*" return "lke:{}".format(self.name) @@ -218,12 +229,13 @@ class ObjectStorage(Enum): """ Access to Object Storage """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "object_storage:*" return "object_storage:{}".format(self.name) @@ -231,32 +243,33 @@ class Longview(Enum): """ Access to Longview """ + read_only = 0 read_write = 1 all = 2 def __repr__(self): - if(self.name == 'all'): + if self.name == "all": return "longview:*" return "longview:{}".format(self.name) _scope_families = { - 'linodes': Linodes, - 'domains': Domains, - 'stackscripts': StackScripts, - 'users': Users, - 'tokens': Tokens, - 'ips': IPs, - 'firewall': Firewalls, - 'tickets': Tickets, - 'clients': Clients, - 'account': Account, - 'events': Events, - 'volumes': Volumes, - 'lke': LKE, - 'object_storage': ObjectStorage, - 'nodebalancers': NodeBalancers, - 'longview': Longview, + "linodes": Linodes, + "domains": Domains, + "stackscripts": StackScripts, + "users": Users, + "tokens": Tokens, + "ips": IPs, + "firewall": Firewalls, + "tickets": Tickets, + "clients": Clients, + "account": Account, + "events": Events, + "volumes": Volumes, + "lke": LKE, + "object_storage": ObjectStorage, + "nodebalancers": NodeBalancers, + "longview": Longview, } @staticmethod @@ -264,17 +277,19 @@ def parse(scopes): ret = [] # special all-scope case - if scopes == '*': - return [ getattr(OAuthScopes._scope_families[s], 'all') - for s in OAuthScopes._scope_families ] # pylint: disable=consider-using-dict-items + if scopes == "*": + return [ + getattr(scope, "all") + for scope in OAuthScopes._scope_families.values() + ] - for scope in scopes.split(','): + for scope in scopes.split(","): resource = access = None - if ':' in scope: - resource, access = scope.split(':') + if ":" in scope: + resource, access = scope.split(":") else: resource = scope - access = '*' + access = "*" parsed_scope = OAuthScopes._get_parsed_scope(resource, access) if parsed_scope: @@ -287,8 +302,8 @@ def _get_parsed_scope(resource, access): resource = resource.lower() access = access.lower() if resource in OAuthScopes._scope_families: - if access == '*': - access = 'delete' + if access == "*": + access = "delete" if hasattr(OAuthScopes._scope_families[resource], access): return getattr(OAuthScopes._scope_families[resource], access) @@ -296,18 +311,20 @@ def _get_parsed_scope(resource, access): @staticmethod def serialize(scopes): - ret = '' + ret = "" if not type(scopes) is list: - scopes = [ scopes ] + scopes = [scopes] for scope in scopes: ret += "{},".format(repr(scope)) if ret: ret = ret[:-1] return ret + class LinodeLoginClient: - def __init__(self, client_id, client_secret, - base_url="https://login.linode.com"): + def __init__( + self, client_id, client_secret, base_url="https://login.linode.com" + ): """ Create a new LinodeLoginClient. These clients do not make any requests on creation, and can safely be created and thrown away as needed. @@ -356,7 +373,7 @@ def begin_oauth_login(): split = list(urlparse(url)) params = { "client_id": self.client_id, - "response_type": "code", # needed for all logins + "response_type": "code", # needed for all logins } if scopes: params["scopes"] = OAuthScopes.serialize(scopes) @@ -398,19 +415,26 @@ def oauth_redirect(): :raise ApiError: If the OAuth exchange fails. """ - r = requests.post(self._login_uri("/oauth/token"), data={ + r = requests.post( + self._login_uri("/oauth/token"), + data={ "code": code, "client_id": self.client_id, - "client_secret": self.client_secret - }) + "client_secret": self.client_secret, + }, + ) if r.status_code != 200: - raise ApiError("OAuth token exchange failed", status=r.status_code, json=r.json()) + raise ApiError( + "OAuth token exchange failed", + status=r.status_code, + json=r.json(), + ) token = r.json()["access_token"] scopes = OAuthScopes.parse(r.json()["scopes"]) - expiry = datetime.now() + timedelta(seconds=r.json()['expires_in']) - refresh_token = r.json()['refresh_token'] + expiry = datetime.now() + timedelta(seconds=r.json()["expires_in"]) + refresh_token = r.json()["refresh_token"] return token, scopes, expiry, refresh_token @@ -434,20 +458,23 @@ def refresh_oauth_token(self, refresh_token): :raise ApiError: If the refresh fails.. """ - r = requests.post(self._login_uri("/oauth/token"), data={ - "grant_type": "refresh_token", - "client_id": self.client_id, - "client_secret": self.client_secret, - "refresh_token": refresh_token, - }) + r = requests.post( + self._login_uri("/oauth/token"), + data={ + "grant_type": "refresh_token", + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": refresh_token, + }, + ) if r.status_code != 200: raise ApiError("Refresh failed", r) token = r.json()["access_token"] scopes = OAuthScopes.parse(r.json()["scopes"]) - expiry = datetime.now() + timedelta(seconds=r.json()['expires_in']) - refresh_token = r.json()['refresh_token'] + expiry = datetime.now() + timedelta(seconds=r.json()["expires_in"]) + refresh_token = r.json()["refresh_token"] return token, scopes, expiry, refresh_token @@ -466,12 +493,14 @@ def expire_token(self, token): :raises ApiError: If the expiration attempt failed. """ - r = requests.post(self._login_uri("/oauth/token/expire"), + r = requests.post( + self._login_uri("/oauth/token/expire"), data={ "client_id": self.client_id, "client_secret": self.client_secret, "token": token, - }) + }, + ) if r.status_code != 200: raise ApiError("Failed to expire token!", r) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index e50853fac..84c2301a8 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -1,3 +1,4 @@ +# isort: skip_file from .base import Base, Property, MappedObject, DATE_FORMAT from .dbase import DerivedBase from .filtering import and_, or_ diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 126f0835d..f3f07c11c 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -1,16 +1,27 @@ from datetime import datetime + import requests from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.objects import (Base, DerivedBase, Domain, Image, Instance, - Property, StackScript, Volume, DATE_FORMAT) +from linode_api4.objects import ( + DATE_FORMAT, + Base, + DerivedBase, + Domain, + Image, + Instance, + Property, + StackScript, + Volume, +) from linode_api4.objects.longview import LongviewClient, LongviewSubscription from linode_api4.objects.nodebalancer import NodeBalancer from linode_api4.objects.support import SupportTicket + class Account(Base): api_endpoint = "/account" - id_attribute = 'email' + id_attribute = "email" properties = { "company": Property(mutable=True), @@ -27,96 +38,98 @@ class Account(Base): "address_2": Property(mutable=True), "tax_id": Property(mutable=True), "capabilities": Property(), - 'credit_card': Property(), + "credit_card": Property(), } class AccountSettings(Base): api_endpoint = "/account/settings" - id_attribute = 'managed' # this isn't actually used + id_attribute = "managed" # this isn't actually used properties = { "network_helper": Property(mutable=True), "managed": Property(), - "longview_subscription": Property(slug_relationship=LongviewSubscription), + "longview_subscription": Property( + slug_relationship=LongviewSubscription + ), "object_storage": Property(), } class Event(Base): - api_endpoint = '/account/events/{id}' + api_endpoint = "/account/events/{id}" properties = { - 'id': Property(identifier=True, filterable=True), - 'percent_complete': Property(volatile=True), - 'created': Property(is_datetime=True, filterable=True), - 'updated': Property(is_datetime=True, filterable=True), - 'seen': Property(), - 'read': Property(), - 'action': Property(), - 'user_id': Property(), - 'username': Property(), - 'entity': Property(), - 'time_remaining': Property(), - 'rate': Property(), - 'status': Property(), + "id": Property(identifier=True, filterable=True), + "percent_complete": Property(volatile=True), + "created": Property(is_datetime=True, filterable=True), + "updated": Property(is_datetime=True, filterable=True), + "seen": Property(), + "read": Property(), + "action": Property(), + "user_id": Property(), + "username": Property(), + "entity": Property(), + "time_remaining": Property(), + "rate": Property(), + "status": Property(), } @property def linode(self): - if self.entity and self.entity.type == 'linode': + if self.entity and self.entity.type == "linode": return Instance(self._client, self.entity.id) return None @property def stackscript(self): - if self.entity and self.entity.type == 'stackscript': + if self.entity and self.entity.type == "stackscript": return StackScript(self._client, self.entity.id) return None @property def domain(self): - if self.entity and self.entity.type == 'domain': + if self.entity and self.entity.type == "domain": return Domain(self._client, self.entity.id) return None @property def nodebalancer(self): - if self.entity and self.entity.type == 'nodebalancer': + if self.entity and self.entity.type == "nodebalancer": return NodeBalancer(self._client, self.entity.id) return None @property def ticket(self): - if self.entity and self.entity.type == 'ticket': + if self.entity and self.entity.type == "ticket": return SupportTicket(self._client, self.entity.id) return None @property def volume(self): - if self.entity and self.entity.type == 'volume': + if self.entity and self.entity.type == "volume": return Volume(self._client, self.entity.id) return None def mark_read(self): - self._client.post('{}/read'.format(Event.api_endpoint), model=self) + self._client.post("{}/read".format(Event.api_endpoint), model=self) class InvoiceItem(DerivedBase): - api_endpoint = '/account/invoices/{invoice_id}/items' - derived_url_path = 'items' - parent_id_name='invoice_id' - id_attribute = 'label' # this has to be something + api_endpoint = "/account/invoices/{invoice_id}/items" + derived_url_path = "items" + parent_id_name = "invoice_id" + id_attribute = "label" # this has to be something properties = { - 'invoice_id': Property(identifier=True), - 'unit_price': Property(), - 'label': Property(), - 'amount': Property(), - 'quantity': Property(), + "invoice_id": Property(identifier=True), + "unit_price": Property(), + "label": Property(), + "amount": Property(), + "quantity": Property(), #'from_date': Property(is_datetime=True), this is populated below from the "from" attribute - 'to': Property(is_datetime=True), + "to": Property(is_datetime=True), #'to_date': Property(is_datetime=True), this is populated below from the "to" attribute - 'type': Property(), + "type": Property(), } def _populate(self, json): @@ -126,8 +139,8 @@ def _populate(self, json): """ super()._populate(json) - self.from_date = datetime.strptime(json['from'], DATE_FORMAT) - self.to_date = datetime.strptime(json['to'], DATE_FORMAT) + self.from_date = datetime.strptime(json["from"], DATE_FORMAT) + self.to_date = datetime.strptime(json["to"], DATE_FORMAT) class Invoice(Base): @@ -158,10 +171,14 @@ def reset_secret(self): """ Resets the client secret for this client. """ - result = self._client.post("{}/reset_secret".format(OAuthClient.api_endpoint), model=self) + result = self._client.post( + "{}/reset_secret".format(OAuthClient.api_endpoint), model=self + ) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when resetting secret!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when resetting secret!", json=result + ) self._populate(result) return self.secret @@ -172,19 +189,23 @@ def thumbnail(self, dump_to=None): If dump_to is given, attempts to write the image to a file at the given location. """ - headers = { - "Authorization": "token {}".format(self._client.token) - } + headers = {"Authorization": "token {}".format(self._client.token)} - result = requests.get('{}/{}/thumbnail'.format(self._client.base_url, - OAuthClient.api_endpoint.format(id=self.id)), - headers=headers) + result = requests.get( + "{}/{}/thumbnail".format( + self._client.base_url, + OAuthClient.api_endpoint.format(id=self.id), + ), + headers=headers, + ) if not result.status_code == 200: - raise ApiError('No thumbnail found for OAuthClient {}'.format(self.id)) + raise ApiError( + "No thumbnail found for OAuthClient {}".format(self.id) + ) if dump_to: - with open(dump_to, 'wb+') as f: + with open(dump_to, "wb+") as f: f.write(result.content) return result.content @@ -201,19 +222,24 @@ def set_thumbnail(self, thumbnail): # TODO this check needs to be smarter - python2 doesn't do it right if not isinstance(thumbnail, bytes): - with open(thumbnail, 'rb') as f: + with open(thumbnail, "rb") as f: thumbnail = f.read() - result = requests.put('{}/{}/thumbnail'.format(self._client.base_url, - OAuthClient.api_endpoint.format(id=self.id)), - headers=headers, data=thumbnail) + result = requests.put( + "{}/{}/thumbnail".format( + self._client.base_url, + OAuthClient.api_endpoint.format(id=self.id), + ), + headers=headers, + data=thumbnail, + ) if not result.status_code == 200: errors = [] j = result.json() - if 'errors' in j: - errors = [ e['reason'] for e in j['errors'] ] - raise ApiError('{}: {}'.format(result.status_code, errors), json=j) + if "errors" in j: + errors = [e["reason"] for e in j["errors"]] + raise ApiError("{}: {}".format(result.status_code, errors), json=j) return True @@ -230,12 +256,12 @@ class Payment(Base): class User(Base): api_endpoint = "/account/users/{id}" - id_attribute = 'username' + id_attribute = "username" properties = { - 'email': Property(), - 'username': Property(identifier=True, mutable=True), - 'restricted': Property(mutable=True), + "email": Property(), + "username": Property(identifier=True, mutable=True), + "restricted": Property(mutable=True), } @property @@ -248,17 +274,22 @@ def grants(self): :returns: The grants for this user. :rtype: linode.objects.account.UserGrants """ - from linode_api4.objects.account import UserGrants # pylint: disable-all - if not hasattr(self, '_grants'): - resp = self._client.get(UserGrants.api_endpoint.format(username=self.username)) + from linode_api4.objects.account import ( # pylint: disable-all + UserGrants, + ) + + if not hasattr(self, "_grants"): + resp = self._client.get( + UserGrants.api_endpoint.format(username=self.username) + ) grants = UserGrants(self._client, self.username, resp) - self._set('_grants', grants) + self._set("_grants", grants) return self._grants def invalidate(self): - if hasattr(self, '_grants'): + if hasattr(self, "_grants"): del self._grants Base.invalidate(self) @@ -267,13 +298,15 @@ def get_obj_grants(): """ Returns Grant keys mapped to Object types. """ - return (('linode', Instance), - ('domain', Domain), - ('stackscript', StackScript), - ('nodebalancer', NodeBalancer), - ('volume', Volume), - ('image', Image), - ('longview', LongviewClient)) + return ( + ("linode", Instance), + ("domain", Domain), + ("stackscript", StackScript), + ("nodebalancer", NodeBalancer), + ("volume", Volume), + ("image", Image), + ("longview", LongviewClient), + ) class Grant: @@ -285,12 +318,13 @@ class Grant: Grants cannot be accessed or updated individually, and are only relevant in the context of a UserGrants object. """ + def __init__(self, client, cls, dct): self._client = client self.cls = cls - self.id = dct['id'] - self.label = dct['label'] - self.permissions = dct['permissions'] + self.id = dct["id"] + self.label = dct["label"] + self.permissions = dct["permissions"] @property def entity(self): @@ -304,7 +338,9 @@ def entity(self): """ # there are no grants for derived types, so this shouldn't happen if not issubclass(self.cls, Base) or issubclass(self.cls, DerivedBase): - raise ValueError("Cannot get entity for non-base-class {}".format(self.cls)) + raise ValueError( + "Cannot get entity for non-base-class {}".format(self.cls) + ) return self.cls(self._client, self.id) def _serialize(self): @@ -312,10 +348,7 @@ def _serialize(self): Returns this grant in as JSON the api will accept. This is only relevant in the context of UserGrants.save """ - return { - 'permissions': self.permissions, - 'id': self.id - } + return {"permissions": self.permissions, "id": self.id} class UserGrants: @@ -328,8 +361,9 @@ class UserGrants: a Base-like model (such as a unique, ID-based endpoint at which to access it), however it has some similarities so that its usage is familiar. """ + api_endpoint = "/account/users/{username}/grants" - parent_id_name = 'username' + parent_id_name = "username" def __init__(self, client, username, json=None): self._client = client @@ -337,9 +371,9 @@ def __init__(self, client, username, json=None): if json is not None: self._populate(json) - + def _populate(self, json): - self.global_grants = type('global_grants', (object,), json['global']) + self.global_grants = type("global_grants", (object,), json["global"]) for key, cls in get_obj_grants(): lst = [] @@ -349,7 +383,11 @@ def _populate(self, json): def save(self): req = { - 'global': {k: v for k, v in vars(self.global_grants).items() if not k.startswith('_')}, + "global": { + k: v + for k, v in vars(self.global_grants).items() + if not k.startswith("_") + }, } for key, _ in get_obj_grants(): @@ -358,7 +396,9 @@ def save(self): lst.append(cg._serialize()) req[key] = lst - result = self._client.put(UserGrants.api_endpoint.format(username=self.username), data=req) + result = self._client.put( + UserGrants.api_endpoint.format(username=self.username), data=req + ) self._populate(result) diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index eb8a0e997..aa558a676 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -3,17 +3,26 @@ from .filtering import FilterableMetaclass - DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" # The interval to reload volatile properties volatile_refresh_timeout = timedelta(seconds=15) + class Property: - def __init__(self, mutable=False, identifier=False, volatile=False, relationship=None, - derived_class=None, is_datetime=False, filterable=False, id_relationship=False, - slug_relationship=False): + def __init__( + self, + mutable=False, + identifier=False, + volatile=False, + relationship=None, + derived_class=None, + is_datetime=False, + filterable=False, + id_relationship=False, + slug_relationship=False, + ): """ A Property is an attribute returned from the API, and defines metadata about that value. These are expected to be used as the values of a @@ -41,6 +50,7 @@ def __init__(self, mutable=False, identifier=False, volatile=False, relationship self.id_relationship = id_relationship self.slug_relationship = slug_relationship + class MappedObject: """ Converts a dict into values accessible with the dot notation. @@ -53,6 +63,7 @@ class MappedObject: object.this # "that" """ + def __init__(self, **vals): self._expand_vals(self.__dict__, **vals) @@ -62,40 +73,44 @@ def _expand_vals(self, target, **vals): vals[v] = MappedObject(**vals[v]) elif type(vals[v]) is list: # oh mama - vals[v] = [ MappedObject(**i) if type(i) is dict else i for i in vals[v] ] + vals[v] = [ + MappedObject(**i) if type(i) is dict else i for i in vals[v] + ] target.update(vals) def __repr__(self): return "Mapping containing {}".format(vars(self).keys()) - + @property def dict(self): return dict(self.__dict__) + class Base(object, metaclass=FilterableMetaclass): """ The Base class knows how to look up api properties of a model, and lazy-load them. """ + properties = {} def __init__(self, client, id, json={}): - self._set('_populated', False) - self._set('_last_updated', datetime.min) - self._set('_client', client) - self._set('_changed', False) + self._set("_populated", False) + self._set("_last_updated", datetime.min) + self._set("_client", client) + self._set("_changed", False) #: self._raw_json is a copy of the json received from the API on population, #: and cannot be relied upon to be current. Local changes to mutable fields #: that have not been saved will not be present, and volatile fields will not #: be updated on access. - self._set('_raw_json', None) + self._set("_raw_json", None) for prop in type(self).properties: self._set(prop, None) - self._set('id', id) - if hasattr(type(self), 'id_attribute'): - self._set(getattr(type(self), 'id_attribute'), id) + self._set("id", id) + if hasattr(type(self), "id_attribute"): + self._set(getattr(type(self), "id_attribute"), id) self._populate(json) @@ -107,30 +122,48 @@ def __getattribute__(self, name): if name in type(self).properties.keys(): # We are accessing a Property if type(self).properties[name].identifier: - pass # don't load identifiers from the server, we have those - elif (object.__getattribute__(self, name) is None and not self._populated \ - or type(self).properties[name].derived_class) \ - or (type(self).properties[name].volatile \ - and object.__getattribute__(self, '_last_updated') - + volatile_refresh_timeout < datetime.now()): + pass # don't load identifiers from the server, we have those + elif ( + object.__getattribute__(self, name) is None + and not self._populated + or type(self).properties[name].derived_class + ) or ( + type(self).properties[name].volatile + and object.__getattribute__(self, "_last_updated") + + volatile_refresh_timeout + < datetime.now() + ): # needs to be loaded from the server if type(self).properties[name].derived_class: - #load derived object(s) - self._set(name, type(self).properties[name].derived_class - ._api_get_derived(self, getattr(self, '_client'))) + # load derived object(s) + self._set( + name, + type(self) + .properties[name] + .derived_class._api_get_derived( + self, getattr(self, "_client") + ), + ) else: self._api_get() elif "{}_id".format(name) in type(self).properties.keys(): # possible id-based relationship - related_type = type(self).properties['{}_id'.format(name)].id_relationship + related_type = ( + type(self).properties["{}_id".format(name)].id_relationship + ) if related_type: # no id, no related object if not getattr(self, "{}_id".format(name)): return None # it is a relationship - relcache_name = '_{}_relcache'.format(name) + relcache_name = "_{}_relcache".format(name) if not hasattr(self, relcache_name): - self._set(relcache_name, related_type(self._client, getattr(self, '{}_id'.format(name)))) + self._set( + relcache_name, + related_type( + self._client, getattr(self, "{}_id".format(name)) + ), + ) return object.__getattribute__(self, relcache_name) return object.__getattribute__(self, name) @@ -147,8 +180,11 @@ def __setattr__(self, name, value): """ if name in type(self).properties.keys(): if not type(self).properties[name].mutable: - raise AttributeError("'{}' is not a mutable field of '{}'" - .format(name, type(self).__name__)) + raise AttributeError( + "'{}' is not a mutable field of '{}'".format( + name, type(self).__name__ + ) + ) self._changed = True @@ -167,13 +203,14 @@ def save(self, force=True) -> bool: if not force and not self._changed: return False - resp = self._client.put(type(self).api_endpoint, model=self, - data=self._serialize()) + resp = self._client.put( + type(self).api_endpoint, model=self, data=self._serialize() + ) - if 'error' in resp: + if "error" in resp: return False - self._set('_changed', False) + self._set("_changed", False) return True @@ -183,7 +220,7 @@ def delete(self): """ resp = self._client.delete(type(self).api_endpoint, model=self) - if 'error' in resp: + if "error" in resp: return False self.invalidate() return True @@ -193,24 +230,30 @@ def invalidate(self): Invalidates all non-identifier Properties this object has locally, causing the next access to re-fetch them from the server """ - for key in [k for k in type(self).properties.keys() - if not type(self).properties[k].identifier]: + for key in [ + k + for k in type(self).properties.keys() + if not type(self).properties[k].identifier + ]: self._set(key, None) - self._set('_populated', False) + self._set("_populated", False) def _serialize(self): """ A helper method to build a dict of all mutable Properties of this object """ - result = { a: getattr(self, a) for a in type(self).properties - if type(self).properties[a].mutable } + result = { + a: getattr(self, a) + for a in type(self).properties + if type(self).properties[a].mutable + } for k, v in result.items(): if isinstance(v, Base): result[k] = v.id - elif isinstance(v,MappedObject): + elif isinstance(v, MappedObject): result[k] = v.dict return result @@ -232,47 +275,62 @@ def _populate(self, json): return # hide the raw JSON away in case someone needs it - self._set('_raw_json', json) - self._set('_updated', False) + self._set("_raw_json", json) + self._set("_updated", False) for key in json: - if key in (k for k in type(self).properties.keys() - if not type(self).properties[k].identifier): - if type(self).properties[key].relationship \ - and not json[key] is None: + if key in ( + k + for k in type(self).properties.keys() + if not type(self).properties[k].identifier + ): + if ( + type(self).properties[key].relationship + and not json[key] is None + ): if isinstance(json[key], list): objs = [] for d in json[key]: - if not 'id' in d: + if not "id" in d: continue new_class = type(self).properties[key].relationship - obj = new_class.make_instance(d['id'], - getattr(self,'_client')) + obj = new_class.make_instance( + d["id"], getattr(self, "_client") + ) if obj: obj._populate(d) objs.append(obj) self._set(key, objs) else: if isinstance(json[key], dict): - related_id = json[key]['id'] + related_id = json[key]["id"] else: related_id = json[key] new_class = type(self).properties[key].relationship - obj = new_class.make_instance(related_id, getattr(self,'_client')) + obj = new_class.make_instance( + related_id, getattr(self, "_client") + ) if obj and isinstance(json[key], dict): obj._populate(json[key]) self._set(key, obj) - elif type(self).properties[key].slug_relationship \ - and not json[key] is None: + elif ( + type(self).properties[key].slug_relationship + and not json[key] is None + ): # create an object of the expected type with the given slug - self._set(key, type(self).properties[key].slug_relationship(self._client, json[key])) + self._set( + key, + type(self) + .properties[key] + .slug_relationship(self._client, json[key]), + ) elif type(json[key]) is dict: self._set(key, MappedObject(**json[key])) elif type(json[key]) is list: # we're going to use MappedObject's behavior with lists to # expand these, then grab the resulting value to set mapping = MappedObject(_list=json[key]) - self._set(key, mapping._list) # pylint: disable=no-member + self._set(key, mapping._list) # pylint: disable=no-member elif type(self).properties[key].is_datetime: try: t = time.strptime(json[key], DATE_FORMAT) @@ -285,8 +343,8 @@ def _populate(self, json): else: self._set(key, json[key]) - self._set('_populated', True) - self._set('_last_updated', datetime.now()) + self._set("_populated", True) + self._set("_last_updated", datetime.now()) def _set(self, name, value): """ @@ -301,7 +359,7 @@ def api_list(cls): Returns a URL that will produce a list of JSON objects of this class' type """ - return '/'.join(cls.api_endpoint.split('/')[:-1]) + return "/".join(cls.api_endpoint.split("/")[:-1]) @staticmethod def make(id, client, cls, parent_id=None, json=None): @@ -316,7 +374,7 @@ def make(id, client, cls, parent_id=None, json=None): :returns: An instance of cls with the given id """ - from .dbase import DerivedBase # pylint: disable-all + from .dbase import DerivedBase # pylint: disable-all if issubclass(cls, DerivedBase): return cls(client, id, parent_id, json) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 04c7997aa..d32bac7a8 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,17 +1,17 @@ -from linode_api4.objects import Base, Property, MappedObject, DerivedBase +from linode_api4.objects import Base, DerivedBase, MappedObject, Property class DatabaseType(Base): - api_endpoint = '/databases/types/{id}' + api_endpoint = "/databases/types/{id}" properties = { - 'deprecated': Property(filterable=True), - 'disk': Property(), - 'engines': Property(), - 'id': Property(identifier=True), - 'label': Property(), - 'memory': Property(), - 'vcpus': Property(), + "deprecated": Property(filterable=True), + "disk": Property(), + "engines": Property(), + "id": Property(identifier=True), + "label": Property(), + "memory": Property(), + "vcpus": Property(), # type_class is populated from the 'class' attribute of the returned JSON } @@ -21,19 +21,19 @@ def _populate(self, json): """ super()._populate(json) - if 'class' in json: - setattr(self, 'type_class', json['class']) + if "class" in json: + setattr(self, "type_class", json["class"]) else: - setattr(self, 'type_class', None) + setattr(self, "type_class", None) class DatabaseEngine(Base): - api_endpoint = '/databases/engines/{id}' + api_endpoint = "/databases/engines/{id}" properties = { - 'id': Property(identifier=True), - 'engine': Property(filterable=True), - 'version': Property(filterable=True), + "id": Property(identifier=True), + "engine": Property(filterable=True), + "version": Property(filterable=True), } def invalidate(self): @@ -41,7 +41,7 @@ def invalidate(self): Clear out cached properties. """ - for attr in ['_instance']: + for attr in ["_instance"]: if hasattr(self, attr): delattr(self, attr) @@ -56,15 +56,15 @@ class DatabaseBackup(DerivedBase): Use the appropriate subclasses for the corresponding database engine. (e.g. MySQLDatabaseBackup) """ - api_endpoint = '' - derived_url_path = 'backups' - parent_id_name = 'database_id' + api_endpoint = "" + derived_url_path = "backups" + parent_id_name = "database_id" properties = { - 'created': Property(is_datetime=True), - 'id': Property(identifier=True), - 'label': Property(), - 'type': Property(), + "created": Property(is_datetime=True), + "id": Property(identifier=True), + "label": Property(), + "type": Property(), } def restore(self): @@ -72,58 +72,64 @@ def restore(self): Restore a backup to a Managed Database on your Account. """ - return self._client.post('{}/restore'.format(self.api_endpoint), model=self) + return self._client.post( + "{}/restore".format(self.api_endpoint), model=self + ) class MySQLDatabaseBackup(DatabaseBackup): - api_endpoint = '/databases/mysql/instances/{database_id}/backups/{id}' + api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" class MongoDBDatabaseBackup(DatabaseBackup): - api_endpoint = '/databases/mongodb/instances/{database_id}/backups/{id}' + api_endpoint = "/databases/mongodb/instances/{database_id}/backups/{id}" class PostgreSQLDatabaseBackup(DatabaseBackup): - api_endpoint = '/databases/postgresql/instances/{database_id}/backups/{id}' + api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" class MySQLDatabase(Base): - api_endpoint = '/databases/mysql/instances/{id}' + api_endpoint = "/databases/mysql/instances/{id}" properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True, filterable=True), - 'allow_list': Property(mutable=True), - 'backups': Property(derived_class=MySQLDatabaseBackup), - 'cluster_size': Property(), - 'created': Property(is_datetime=True), - 'encrypted': Property(), - 'engine': Property(filterable=True), - 'hosts': Property(), - 'port': Property(), - 'region': Property(filterable=True), - 'replication_type': Property(), - 'ssl_connection': Property(), - 'status': Property(volatile=True, filterable=True), - 'type': Property(filterable=True), - 'updated': Property(volatile=True, is_datetime=True), - 'updates': Property(mutable=True), - 'version': Property(filterable=True), + "id": Property(identifier=True), + "label": Property(mutable=True, filterable=True), + "allow_list": Property(mutable=True), + "backups": Property(derived_class=MySQLDatabaseBackup), + "cluster_size": Property(), + "created": Property(is_datetime=True), + "encrypted": Property(), + "engine": Property(filterable=True), + "hosts": Property(), + "port": Property(), + "region": Property(filterable=True), + "replication_type": Property(), + "ssl_connection": Property(), + "status": Property(volatile=True, filterable=True), + "type": Property(filterable=True), + "updated": Property(volatile=True, is_datetime=True), + "updates": Property(mutable=True), + "version": Property(filterable=True), } @property def credentials(self): - if not hasattr(self, '_credentials'): - resp = self._client.get('{}/credentials'.format(MySQLDatabase.api_endpoint), model=self) - self._set('_credentials', MappedObject(**resp)) + if not hasattr(self, "_credentials"): + resp = self._client.get( + "{}/credentials".format(MySQLDatabase.api_endpoint), model=self + ) + self._set("_credentials", MappedObject(**resp)) return self._credentials @property def ssl(self): - if not hasattr(self, '_ssl'): - resp = self._client.get('{}/ssl'.format(MySQLDatabase.api_endpoint), model=self) - self._set('_ssl', MappedObject(**resp)) + if not hasattr(self, "_ssl"): + resp = self._client.get( + "{}/ssl".format(MySQLDatabase.api_endpoint), model=self + ) + self._set("_ssl", MappedObject(**resp)) return self._ssl @@ -134,7 +140,10 @@ def credentials_reset(self): self.invalidate() - return self._client.post('{}/credentials/reset'.format(MySQLDatabase.api_endpoint), model=self) + return self._client.post( + "{}/credentials/reset".format(MySQLDatabase.api_endpoint), + model=self, + ) def patch(self): """ @@ -143,7 +152,9 @@ def patch(self): self.invalidate() - return self._client.post('{}/patch'.format(MySQLDatabase.api_endpoint), model=self) + return self._client.post( + "{}/patch".format(MySQLDatabase.api_endpoint), model=self + ) def backup_create(self, label, **kwargs): """ @@ -154,11 +165,15 @@ def backup_create(self, label, **kwargs): """ params = { - 'label': label, + "label": label, } params.update(kwargs) - self._client.post('{}/backups'.format(MySQLDatabase.api_endpoint), model=self, data=params) + self._client.post( + "{}/backups".format(MySQLDatabase.api_endpoint), + model=self, + data=params, + ) self.invalidate() def invalidate(self): @@ -166,7 +181,7 @@ def invalidate(self): Clear out cached properties. """ - for attr in ['_ssl', '_credentials']: + for attr in ["_ssl", "_credentials"]: if hasattr(self, attr): delattr(self, attr) @@ -174,43 +189,48 @@ def invalidate(self): class PostgreSQLDatabase(Base): - api_endpoint = '/databases/postgresql/instances/{id}' + api_endpoint = "/databases/postgresql/instances/{id}" properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True, filterable=True), - 'allow_list': Property(mutable=True), - 'backups': Property(derived_class=PostgreSQLDatabaseBackup), - 'cluster_size': Property(), - 'created': Property(is_datetime=True), - 'encrypted': Property(), - 'engine': Property(filterable=True), - 'hosts': Property(), - 'port': Property(), - 'region': Property(filterable=True), - 'replication_commit_type': Property(), - 'replication_type': Property(), - 'ssl_connection': Property(), - 'status': Property(volatile=True, filterable=True), - 'type': Property(filterable=True), - 'updated': Property(volatile=True, is_datetime=True), - 'updates': Property(mutable=True), - 'version': Property(filterable=True), + "id": Property(identifier=True), + "label": Property(mutable=True, filterable=True), + "allow_list": Property(mutable=True), + "backups": Property(derived_class=PostgreSQLDatabaseBackup), + "cluster_size": Property(), + "created": Property(is_datetime=True), + "encrypted": Property(), + "engine": Property(filterable=True), + "hosts": Property(), + "port": Property(), + "region": Property(filterable=True), + "replication_commit_type": Property(), + "replication_type": Property(), + "ssl_connection": Property(), + "status": Property(volatile=True, filterable=True), + "type": Property(filterable=True), + "updated": Property(volatile=True, is_datetime=True), + "updates": Property(mutable=True), + "version": Property(filterable=True), } @property def credentials(self): - if not hasattr(self, '_credentials'): - resp = self._client.get('{}/credentials'.format(PostgreSQLDatabase.api_endpoint), model=self) - self._set('_credentials', MappedObject(**resp)) + if not hasattr(self, "_credentials"): + resp = self._client.get( + "{}/credentials".format(PostgreSQLDatabase.api_endpoint), + model=self, + ) + self._set("_credentials", MappedObject(**resp)) return self._credentials @property def ssl(self): - if not hasattr(self, '_ssl'): - resp = self._client.get('{}/ssl'.format(PostgreSQLDatabase.api_endpoint), model=self) - self._set('_ssl', MappedObject(**resp)) + if not hasattr(self, "_ssl"): + resp = self._client.get( + "{}/ssl".format(PostgreSQLDatabase.api_endpoint), model=self + ) + self._set("_ssl", MappedObject(**resp)) return self._ssl @@ -221,7 +241,10 @@ def credentials_reset(self): self.invalidate() - return self._client.post('{}/credentials/reset'.format(PostgreSQLDatabase.api_endpoint), model=self) + return self._client.post( + "{}/credentials/reset".format(PostgreSQLDatabase.api_endpoint), + model=self, + ) def patch(self): """ @@ -230,7 +253,9 @@ def patch(self): self.invalidate() - return self._client.post('{}/patch'.format(PostgreSQLDatabase.api_endpoint), model=self) + return self._client.post( + "{}/patch".format(PostgreSQLDatabase.api_endpoint), model=self + ) def backup_create(self, label, **kwargs): """ @@ -238,11 +263,15 @@ def backup_create(self, label, **kwargs): """ params = { - 'label': label, + "label": label, } params.update(kwargs) - self._client.post('{}/backups'.format(PostgreSQLDatabase.api_endpoint), model=self, data=params) + self._client.post( + "{}/backups".format(PostgreSQLDatabase.api_endpoint), + model=self, + data=params, + ) self.invalidate() def invalidate(self): @@ -250,7 +279,7 @@ def invalidate(self): Clear out cached properties. """ - for attr in ['_ssl', '_credentials']: + for attr in ["_ssl", "_credentials"]: if hasattr(self, attr): delattr(self, attr) @@ -258,45 +287,50 @@ def invalidate(self): class MongoDBDatabase(Base): - api_endpoint = '/databases/mongodb/instances/{id}' + api_endpoint = "/databases/mongodb/instances/{id}" properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True, filterable=True), - 'allow_list': Property(mutable=True), - 'backups': Property(derived_class=MongoDBDatabaseBackup), - 'cluster_size': Property(), - 'compression_type': Property(), - 'created': Property(is_datetime=True), - 'encrypted': Property(), - 'engine': Property(filterable=True), - 'hosts': Property(), - 'peers': Property(), - 'port': Property(), - 'region': Property(filterable=True), - 'replica_set': Property(), - 'ssl_connection': Property(), - 'status': Property(volatile=True, filterable=True), - 'storage_engine': Property(), - 'type': Property(filterable=True), - 'updated': Property(volatile=True, is_datetime=True), - 'updates': Property(mutable=True), - 'version': Property(filterable=True), + "id": Property(identifier=True), + "label": Property(mutable=True, filterable=True), + "allow_list": Property(mutable=True), + "backups": Property(derived_class=MongoDBDatabaseBackup), + "cluster_size": Property(), + "compression_type": Property(), + "created": Property(is_datetime=True), + "encrypted": Property(), + "engine": Property(filterable=True), + "hosts": Property(), + "peers": Property(), + "port": Property(), + "region": Property(filterable=True), + "replica_set": Property(), + "ssl_connection": Property(), + "status": Property(volatile=True, filterable=True), + "storage_engine": Property(), + "type": Property(filterable=True), + "updated": Property(volatile=True, is_datetime=True), + "updates": Property(mutable=True), + "version": Property(filterable=True), } @property def credentials(self): - if not hasattr(self, '_credentials'): - resp = self._client.get('{}/credentials'.format(MongoDBDatabase.api_endpoint), model=self) - self._set('_credentials', MappedObject(**resp)) + if not hasattr(self, "_credentials"): + resp = self._client.get( + "{}/credentials".format(MongoDBDatabase.api_endpoint), + model=self, + ) + self._set("_credentials", MappedObject(**resp)) return self._credentials @property def ssl(self): - if not hasattr(self, '_ssl'): - resp = self._client.get('{}/ssl'.format(MongoDBDatabase.api_endpoint), model=self) - self._set('_ssl', MappedObject(**resp)) + if not hasattr(self, "_ssl"): + resp = self._client.get( + "{}/ssl".format(MongoDBDatabase.api_endpoint), model=self + ) + self._set("_ssl", MappedObject(**resp)) return self._ssl @@ -307,7 +341,10 @@ def credentials_reset(self): self.invalidate() - return self._client.post('{}/credentials/reset'.format(MongoDBDatabase.api_endpoint), model=self) + return self._client.post( + "{}/credentials/reset".format(MongoDBDatabase.api_endpoint), + model=self, + ) def patch(self): """ @@ -316,7 +353,9 @@ def patch(self): self.invalidate() - return self._client.post('{}/patch'.format(MongoDBDatabase.api_endpoint), model=self) + return self._client.post( + "{}/patch".format(MongoDBDatabase.api_endpoint), model=self + ) def backup_create(self, label, **kwargs): """ @@ -324,11 +363,15 @@ def backup_create(self, label, **kwargs): """ params = { - 'label': label, + "label": label, } params.update(kwargs) - self._client.post('{}/backups'.format(MongoDBDatabase.api_endpoint), model=self, data=params) + self._client.post( + "{}/backups".format(MongoDBDatabase.api_endpoint), + model=self, + data=params, + ) self.invalidate() def invalidate(self): @@ -336,7 +379,7 @@ def invalidate(self): Clear out cached properties. """ - for attr in ['_ssl', '_credentials']: + for attr in ["_ssl", "_credentials"]: if hasattr(self, attr): delattr(self, attr) @@ -344,9 +387,9 @@ def invalidate(self): ENGINE_TYPE_TRANSLATION = { - 'mysql': MySQLDatabase, - 'postgresql': PostgreSQLDatabase, - 'mongodb': MongoDBDatabase, + "mysql": MySQLDatabase, + "postgresql": PostgreSQLDatabase, + "mongodb": MongoDBDatabase, } @@ -355,24 +398,24 @@ class Database(Base): A generic Database instance. """ - api_endpoint = '/databases/instances/{id}' + api_endpoint = "/databases/instances/{id}" properties = { - 'id': Property(), - 'label': Property(), - 'allow_list': Property(), - 'cluster_size': Property(), - 'created': Property(), - 'encrypted': Property(), - 'engine': Property(), - 'hosts': Property(), - 'instance_uri': Property(), - 'region': Property(), - 'status': Property(), - 'type': Property(), - 'updated': Property(), - 'updates': Property(), - 'version': Property(), + "id": Property(), + "label": Property(), + "allow_list": Property(), + "cluster_size": Property(), + "created": Property(), + "encrypted": Property(), + "engine": Property(), + "hosts": Property(), + "instance_uri": Property(), + "region": Property(), + "status": Property(), + "type": Property(), + "updated": Property(), + "updates": Property(), + "version": Property(), } @property @@ -391,10 +434,13 @@ def instance(self): print(f"{db.hosts.primary}: {db.instance.credentials.username} {db.instance.credentials.password}") """ - if not hasattr(self, '_instance'): + if not hasattr(self, "_instance"): if self.engine not in ENGINE_TYPE_TRANSLATION: return None - self._set('_instance', ENGINE_TYPE_TRANSLATION[self.engine](self._client, self.id)) + self._set( + "_instance", + ENGINE_TYPE_TRANSLATION[self.engine](self._client, self.id), + ) return self._instance diff --git a/linode_api4/objects/dbase.py b/linode_api4/objects/dbase.py index e4817ccf2..3bd5bb7df 100644 --- a/linode_api4/objects/dbase.py +++ b/linode_api4/objects/dbase.py @@ -7,8 +7,9 @@ class DerivedBase(Base): (for example, a disk belongs to a linode). These objects have their own endpoints, but they are below another object in the hierarchy (i.e. /linodes/lnde_123/disks/disk_123) """ - derived_url_path = '' #override in child classes - parent_id_name = 'parent_id' #override in child classes + + derived_url_path = "" # override in child classes + parent_id_name = "parent_id" # override in child classes def __init__(self, client, id, parent_id, json={}): Base.__init__(self, client, id, json=json) @@ -17,6 +18,10 @@ def __init__(self, client, id, parent_id, json={}): @classmethod def _api_get_derived(cls, parent, client): - base_url = "{}/{}".format(type(parent).api_endpoint, cls.derived_url_path) - - return client._get_objects(base_url, cls, model=parent, parent_id=parent.id) + base_url = "{}/{}".format( + type(parent).api_endpoint, cls.derived_url_path + ) + + return client._get_objects( + base_url, cls, model=parent, parent_id=parent.id + ) diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index 01adb0a0f..8e769340b 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -8,53 +8,56 @@ class DomainRecord(DerivedBase): parent_id_name = "domain_id" properties = { - 'id': Property(identifier=True), - 'domain_id': Property(identifier=True), - 'type': Property(), - 'name': Property(mutable=True, filterable=True), - 'target': Property(mutable=True, filterable=True), - 'priority': Property(mutable=True), - 'weight': Property(mutable=True), - 'port': Property(mutable=True), - 'service': Property(mutable=True), - 'protocol': Property(mutable=True), - 'ttl_sec': Property(mutable=True), - 'tag': Property(mutable=True), + "id": Property(identifier=True), + "domain_id": Property(identifier=True), + "type": Property(), + "name": Property(mutable=True, filterable=True), + "target": Property(mutable=True, filterable=True), + "priority": Property(mutable=True), + "weight": Property(mutable=True), + "port": Property(mutable=True), + "service": Property(mutable=True), + "protocol": Property(mutable=True), + "ttl_sec": Property(mutable=True), + "tag": Property(mutable=True), } class Domain(Base): api_endpoint = "/domains/{id}" properties = { - 'id': Property(identifier=True), - 'domain': Property(mutable=True, filterable=True), - 'group': Property(mutable=True, filterable=True), - 'description': Property(mutable=True), - 'status': Property(mutable=True), - 'soa_email': Property(mutable=True), - 'retry_sec': Property(mutable=True), - 'master_ips': Property(mutable=True, filterable=True), - 'axfr_ips': Property(mutable=True), - 'expire_sec': Property(mutable=True), - 'refresh_sec': Property(mutable=True), - 'ttl_sec': Property(mutable=True), - 'records': Property(derived_class=DomainRecord), - 'type': Property(mutable=True), - 'tags': Property(mutable=True), + "id": Property(identifier=True), + "domain": Property(mutable=True, filterable=True), + "group": Property(mutable=True, filterable=True), + "description": Property(mutable=True), + "status": Property(mutable=True), + "soa_email": Property(mutable=True), + "retry_sec": Property(mutable=True), + "master_ips": Property(mutable=True, filterable=True), + "axfr_ips": Property(mutable=True), + "expire_sec": Property(mutable=True), + "refresh_sec": Property(mutable=True), + "ttl_sec": Property(mutable=True), + "records": Property(derived_class=DomainRecord), + "type": Property(mutable=True), + "tags": Property(mutable=True), } def record_create(self, record_type, **kwargs): - params = { "type": record_type, } params.update(kwargs) - result = self._client.post("{}/records".format(Domain.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/records".format(Domain.api_endpoint), model=self, data=params + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating domain record!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating domain record!", json=result + ) - zr = DomainRecord(self._client, result['id'], self.id, result) + zr = DomainRecord(self._client, result["id"], self.id, result) return zr diff --git a/linode_api4/objects/filtering.py b/linode_api4/objects/filtering.py index f8a97fd54..c017a8f1f 100644 --- a/linode_api4/objects/filtering.py +++ b/linode_api4/objects/filtering.py @@ -120,77 +120,80 @@ class Filter: between class attributes of filterable classes (see above). Filters can be combined with :any:`and_` and :any:`or_`. """ + def __init__(self, dct): self.dct = dct def __or__(self, other): if not isinstance(other, Filter): raise TypeError("You can only or Filter types!") - if '+or' in self.dct: - return Filter({ '+or': self.dct['+or'] + [ other.dct ] }) + if "+or" in self.dct: + return Filter({"+or": self.dct["+or"] + [other.dct]}) else: - return Filter({ '+or': [self.dct, other.dct] }) + return Filter({"+or": [self.dct, other.dct]}) def __and__(self, other): if not isinstance(other, Filter): raise TypeError("You can only and Filter types!") - if '+and' in self.dct: - return Filter({ '+and': self.dct['+and'] + [ other.dct ] }) + if "+and" in self.dct: + return Filter({"+and": self.dct["+and"] + [other.dct]}) else: - return Filter({ '+and': [self.dct, other.dct] }) + return Filter({"+and": [self.dct, other.dct]}) def order_by(self, field, desc=False): # we can't include two order_bys - if '+order_by' in self.dct: + if "+order_by" in self.dct: raise AssertionError("You may only order by once!") if not isinstance(field, FilterableAttribute): raise TypeError("Can only order by filterable attributes!") - self.dct['+order_by'] = field.name + self.dct["+order_by"] = field.name if desc: - self.dct['+order'] = 'desc' + self.dct["+order"] = "desc" return self def limit(self, limit): # we can't limit twice - if '+limit' in self.dct: + if "+limit" in self.dct: raise AssertionError("You may only limit once!") if not type(limit) == int: raise TypeError("Limit must be an int!") - self.dct['+limit'] = limit + self.dct["+limit"] = limit return self + class FilterableAttribute: def __init__(self, name): self.name = name def __eq__(self, other): - return Filter({ self.name: other }) + return Filter({self.name: other}) def __ne__(self, other): - return Filter({ self.name: { "+neq": other } }) + return Filter({self.name: {"+neq": other}}) - # "in" evaluates the return value - have to use + # "in" evaluates the return value - have to use # type.contains instead def contains(self, other): - return Filter({ self.name: { "+contains": other } }) + return Filter({self.name: {"+contains": other}}) def __gt__(self, other): - return Filter({ self.name: { "+gt": other } }) + return Filter({self.name: {"+gt": other}}) def __lt__(self, other): - return Filter({ self.name: { "+lt": other } }) + return Filter({self.name: {"+lt": other}}) def __ge__(self, other): - return Filter({ self.name: { "+gte": other } }) + return Filter({self.name: {"+gte": other}}) def __le__(self, other): - return Filter({ self.name: { "+lte": other } }) + return Filter({self.name: {"+lte": other}}) + class NonFilterableAttribute: def __init__(self, clsname, atrname): @@ -198,29 +201,44 @@ def __init__(self, clsname, atrname): self.atrname = atrname def __eq__(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) def __ne__(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) def contains(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) def __gt__(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) def __lt__(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) def __ge__(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) def __le__(self, other): - raise AttributeError("{} cannot be filtered by {}".format(self.clsname, self.atrname)) + raise AttributeError( + "{} cannot be filtered by {}".format(self.clsname, self.atrname) + ) + class FilterableMetaclass(type): def __init__(cls, name, bases, dct): - if hasattr(cls, 'properties'): + if hasattr(cls, "properties"): for key in cls.properties.keys(): setattr(cls, key, FilterableAttribute(key)) diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index a44246ae8..b7d763d00 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -5,7 +5,8 @@ class Image(Base): """ An Image is something a Linode Instance or Disk can be deployed from. """ - api_endpoint = '/images/{id}' + + api_endpoint = "/images/{id}" properties = { "id": Property(identifier=True), @@ -21,5 +22,5 @@ class Image(Base): "is_public": Property(), "vendor": Property(), "size": Property(), - "deprecated": Property() + "deprecated": Property(), } diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 7772f604e..3a9ec9565 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -18,63 +18,66 @@ class Backup(DerivedBase): - api_endpoint = '/linode/instances/{linode_id}/backups/{id}' - derived_url_path = 'backups' - parent_id_name='linode_id' + api_endpoint = "/linode/instances/{linode_id}/backups/{id}" + derived_url_path = "backups" + parent_id_name = "linode_id" properties = { - 'id': Property(identifier=True), - 'created': Property(is_datetime=True), - 'duration': Property(), - 'updated': Property(is_datetime=True), - 'finished': Property(is_datetime=True), - 'message': Property(), - 'status': Property(volatile=True), - 'type': Property(), - 'linode_id': Property(identifier=True), - 'label': Property(), - 'configs': Property(), - 'disks': Property(), - 'region': Property(slug_relationship=Region), - 'available': Property() + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "duration": Property(), + "updated": Property(is_datetime=True), + "finished": Property(is_datetime=True), + "message": Property(), + "status": Property(volatile=True), + "type": Property(), + "linode_id": Property(identifier=True), + "label": Property(), + "configs": Property(), + "disks": Property(), + "region": Property(slug_relationship=Region), + "available": Property(), } def restore_to(self, linode, **kwargs): d = { - "linode_id": linode.id if issubclass(type(linode), Base) else linode, + "linode_id": linode.id + if issubclass(type(linode), Base) + else linode, } d.update(kwargs) - self._client.post("{}/restore".format(Backup.api_endpoint), model=self, - data=d) + self._client.post( + "{}/restore".format(Backup.api_endpoint), model=self, data=d + ) return True class Disk(DerivedBase): - api_endpoint = '/linode/instances/{linode_id}/disks/{id}' - derived_url_path = 'disks' - parent_id_name='linode_id' + api_endpoint = "/linode/instances/{linode_id}/disks/{id}" + derived_url_path = "disks" + parent_id_name = "linode_id" properties = { - 'id': Property(identifier=True), - 'created': Property(is_datetime=True), - 'label': Property(mutable=True, filterable=True), - 'size': Property(filterable=True), - 'status': Property(filterable=True, volatile=True), - 'filesystem': Property(), - 'updated': Property(is_datetime=True), - 'linode_id': Property(identifier=True), + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "label": Property(mutable=True, filterable=True), + "size": Property(filterable=True), + "status": Property(filterable=True, volatile=True), + "filesystem": Property(), + "updated": Property(is_datetime=True), + "linode_id": Property(identifier=True), } - def duplicate(self): - d = self._client.post('{}/clone'.format(Disk.api_endpoint), model=self) + d = self._client.post("{}/clone".format(Disk.api_endpoint), model=self) - if not 'id' in d: - raise UnexpectedResponseError('Unexpected response duplicating disk!', json=d) - - return Disk(self._client, d["id"], self.linode_id) + if not "id" in d: + raise UnexpectedResponseError( + "Unexpected response duplicating disk!", json=d + ) + return Disk(self._client, d["id"], self.linode_id) def reset_root_password(self, root_password=None): rpass = root_password @@ -82,11 +85,12 @@ def reset_root_password(self, root_password=None): rpass = Instance.generate_root_password() params = { - 'password': rpass, + "password": rpass, } - self._client.post('{}/password'.format(Disk.api_endpoint), model=self, data=params) - + self._client.post( + "{}/password".format(Disk.api_endpoint), model=self, data=params + ) def resize(self, new_size): """ @@ -104,13 +108,17 @@ def resize(self, new_size): :returns: True if the resize was initiated successfully. :rtype: bool """ - self._client.post('{}/resize'.format(Disk.api_endpoint), model=self, data={"size": new_size}) + self._client.post( + "{}/resize".format(Disk.api_endpoint), + model=self, + data={"size": new_size}, + ) return True class Kernel(Base): - api_endpoint="/linode/kernels/{id}" + api_endpoint = "/linode/kernels/{id}" properties = { "created": Property(is_datetime=True), "deprecated": Property(filterable=True), @@ -123,24 +131,24 @@ class Kernel(Base): "architecture": Property(filterable=True), "xen": Property(filterable=True), "built": Property(), - "pvops": Property(filterable=True) + "pvops": Property(filterable=True), } class Type(Base): api_endpoint = "/linode/types/{id}" properties = { - 'disk': Property(filterable=True), - 'id': Property(identifier=True), - 'label': Property(filterable=True), - 'network_out': Property(filterable=True), - 'price': Property(), - 'addons': Property(), - 'memory': Property(filterable=True), - 'transfer': Property(filterable=True), - 'vcpus': Property(filterable=True), - 'gpus': Property(filterable=True), - 'successor': Property(), + "disk": Property(filterable=True), + "id": Property(identifier=True), + "label": Property(filterable=True), + "network_out": Property(filterable=True), + "price": Property(), + "addons": Property(), + "memory": Property(filterable=True), + "transfer": Property(filterable=True), + "vcpus": Property(filterable=True), + "gpus": Property(filterable=True), + "successor": Property(), # type_class is populated from the 'class' attribute of the returned JSON } @@ -150,13 +158,13 @@ def _populate(self, json): """ super()._populate(json) - if 'class' in json: - setattr(self, 'type_class', json['class']) + if "class" in json: + setattr(self, "type_class", json["class"]) else: - setattr(self, 'type_class', None) + setattr(self, "type_class", None) # allow filtering on this converted type - type_class = FilterableAttribute('class') + type_class = FilterableAttribute("class") class ConfigInterface: @@ -164,6 +172,7 @@ class ConfigInterface: This is a helper class used to populate 'interfaces' in the Config calss below. """ + def __init__(self, purpose, label="", ipam_address=""): """ Creates a new ConfigInterface @@ -197,20 +206,19 @@ def _serialize(self): } - class Config(DerivedBase): - api_endpoint="/linode/instances/{linode_id}/configs/{id}" - derived_url_path="configs" - parent_id_name="linode_id" + api_endpoint = "/linode/instances/{linode_id}/configs/{id}" + derived_url_path = "configs" + parent_id_name = "linode_id" properties = { "id": Property(identifier=True), "linode_id": Property(identifier=True), - "helpers": Property(),#TODO: mutable=True), + "helpers": Property(), # TODO: mutable=True), "created": Property(is_datetime=True), "root_device": Property(mutable=True), "kernel": Property(relationship=Kernel, mutable=True, filterable=True), - "devices": Property(filterable=True),#TODO: mutable=True), + "devices": Property(filterable=True), # TODO: mutable=True), "initrd": Property(relationship=Disk), "updated": Property(), "comments": Property(mutable=True, filterable=True), @@ -218,8 +226,8 @@ class Config(DerivedBase): "run_level": Property(mutable=True, filterable=True), "virt_mode": Property(mutable=True, filterable=True), "memory_limit": Property(mutable=True, filterable=True), - "interfaces": Property(mutable=True), # gets setup in _populate below - "helpers": Property(mutable=True) + "interfaces": Property(mutable=True), # gets setup in _populate below + "helpers": Property(mutable=True), } def _populate(self, json): @@ -227,31 +235,37 @@ def _populate(self, json): Map devices more nicely while populating. """ # needed here to avoid circular imports - from .volume import Volume # pylint: disable=import-outside-toplevel + from .volume import Volume # pylint: disable=import-outside-toplevel DerivedBase._populate(self, json) devices = {} - for device_index, device in json['devices'].items(): + for device_index, device in json["devices"].items(): if not device: devices[device_index] = None continue dev = None - if 'disk_id' in device and device['disk_id']: # this is a disk - dev = Disk.make_instance(device['disk_id'], self._client, - parent_id=self.linode_id) + if "disk_id" in device and device["disk_id"]: # this is a disk + dev = Disk.make_instance( + device["disk_id"], self._client, parent_id=self.linode_id + ) else: - dev = Volume.make_instance(device['volume_id'], self._client, - parent_id=self.linode_id) + dev = Volume.make_instance( + device["volume_id"], self._client, parent_id=self.linode_id + ) devices[device_index] = dev - self._set('devices', MappedObject(**devices)) + self._set("devices", MappedObject(**devices)) interfaces = [] if "interfaces" in json: interfaces = [ - ConfigInterface(c["purpose"], label=c["label"], ipam_address=c["ipam_address"]) + ConfigInterface( + c["purpose"], + label=c["label"], + ipam_address=c["ipam_address"], + ) for c in json["interfaces"] ] @@ -275,28 +289,28 @@ def _serialize(self): class Instance(Base): - api_endpoint = '/linode/instances/{id}' + api_endpoint = "/linode/instances/{id}" properties = { - 'id': Property(identifier=True, filterable=True), - 'label': Property(mutable=True, filterable=True), - 'group': Property(mutable=True, filterable=True), - 'status': Property(volatile=True), - 'created': Property(is_datetime=True), - 'updated': Property(volatile=True, is_datetime=True), - 'region': Property(slug_relationship=Region, filterable=True), - 'alerts': Property(mutable=True), - 'image': Property(slug_relationship=Image, filterable=True), - 'disks': Property(derived_class=Disk), - 'configs': Property(derived_class=Config), - 'type': Property(slug_relationship=Type), - 'backups': Property(), - 'ipv4': Property(), - 'ipv6': Property(), - 'hypervisor': Property(), - 'specs': Property(), - 'tags': Property(mutable=True), - 'host_uuid': Property(), - 'watchdog_enabled': Property(), + "id": Property(identifier=True, filterable=True), + "label": Property(mutable=True, filterable=True), + "group": Property(mutable=True, filterable=True), + "status": Property(volatile=True), + "created": Property(is_datetime=True), + "updated": Property(volatile=True, is_datetime=True), + "region": Property(slug_relationship=Region, filterable=True), + "alerts": Property(mutable=True), + "image": Property(slug_relationship=Image, filterable=True), + "disks": Property(derived_class=Disk), + "configs": Property(derived_class=Config), + "type": Property(slug_relationship=Type), + "backups": Property(), + "ipv4": Property(), + "ipv6": Property(), + "hypervisor": Property(), + "specs": Property(), + "tags": Property(mutable=True), + "host_uuid": Property(), + "watchdog_enabled": Property(), } @property @@ -305,54 +319,66 @@ def ips(self): The ips related collection is not normalized like the others, so we have to make an ad-hoc object to return for its response """ - if not hasattr(self, '_ips'): - result = self._client.get("{}/ips".format(Instance.api_endpoint), model=self) + if not hasattr(self, "_ips"): + result = self._client.get( + "{}/ips".format(Instance.api_endpoint), model=self + ) if not "ipv4" in result: - raise UnexpectedResponseError('Unexpected response loading IPs', json=result) + raise UnexpectedResponseError( + "Unexpected response loading IPs", json=result + ) v4pub = [] - for c in result['ipv4']['public']: - i = IPAddress(self._client, c['address'], c) + for c in result["ipv4"]["public"]: + i = IPAddress(self._client, c["address"], c) v4pub.append(i) v4pri = [] - for c in result['ipv4']['private']: - i = IPAddress(self._client, c['address'], c) + for c in result["ipv4"]["private"]: + i = IPAddress(self._client, c["address"], c) v4pri.append(i) shared_ips = [] - for c in result['ipv4']['shared']: - i = IPAddress(self._client, c['address'], c) + for c in result["ipv4"]["shared"]: + i = IPAddress(self._client, c["address"], c) shared_ips.append(i) reserved = [] - for c in result['ipv4']['reserved']: - i = IPAddress(self._client, c['address'], c) + for c in result["ipv4"]["reserved"]: + i = IPAddress(self._client, c["address"], c) reserved.append(i) - slaac = IPAddress(self._client, result['ipv6']['slaac']['address'], - result['ipv6']['slaac']) - link_local = IPAddress(self._client, result['ipv6']['link_local']['address'], - result['ipv6']['link_local']) - - pools = [IPv6Pool(self._client, result['ipv6']['global']['range'])] - - ips = MappedObject(**{ - "ipv4": { - "public": v4pub, - "private": v4pri, - "shared": shared_ips, - "reserved": reserved, - }, - "ipv6": { - "slaac": slaac, - "link_local": link_local, - "pools": pools, - }, - }) - - self._set('_ips', ips) + slaac = IPAddress( + self._client, + result["ipv6"]["slaac"]["address"], + result["ipv6"]["slaac"], + ) + link_local = IPAddress( + self._client, + result["ipv6"]["link_local"]["address"], + result["ipv6"]["link_local"], + ) + + pools = [IPv6Pool(self._client, result["ipv6"]["global"]["range"])] + + ips = MappedObject( + **{ + "ipv4": { + "public": v4pub, + "private": v4pri, + "shared": shared_ips, + "reserved": reserved, + }, + "ipv6": { + "slaac": slaac, + "link_local": link_local, + "pools": pools, + }, + } + ) + + self._set("_ips", ips) return self._ips @@ -361,116 +387,149 @@ def available_backups(self): """ The backups response contains what backups are available to be restored. """ - if not hasattr(self, '_avail_backups'): - result = self._client.get("{}/backups".format(Instance.api_endpoint), model=self) + if not hasattr(self, "_avail_backups"): + result = self._client.get( + "{}/backups".format(Instance.api_endpoint), model=self + ) - if not 'automatic' in result: - raise UnexpectedResponseError('Unexpected response loading available backups!', json=result) + if not "automatic" in result: + raise UnexpectedResponseError( + "Unexpected response loading available backups!", + json=result, + ) automatic = [] - for a in result['automatic']: - cur = Backup(self._client, a['id'], self.id, a) + for a in result["automatic"]: + cur = Backup(self._client, a["id"], self.id, a) automatic.append(cur) snap = None - if result['snapshot']['current']: - snap = Backup(self._client, result['snapshot']['current']['id'], self.id, - result['snapshot']['current']) + if result["snapshot"]["current"]: + snap = Backup( + self._client, + result["snapshot"]["current"]["id"], + self.id, + result["snapshot"]["current"], + ) psnap = None - if result['snapshot']['in_progress']: - psnap = Backup(self._client, result['snapshot']['in_progress']['id'], self.id, - result['snapshot']['in_progress']) - - self._set('_avail_backups', MappedObject(**{ - "automatic": automatic, - "snapshot": { - "current": snap, - "in_progress": psnap, - } - })) + if result["snapshot"]["in_progress"]: + psnap = Backup( + self._client, + result["snapshot"]["in_progress"]["id"], + self.id, + result["snapshot"]["in_progress"], + ) + + self._set( + "_avail_backups", + MappedObject( + **{ + "automatic": automatic, + "snapshot": { + "current": snap, + "in_progress": psnap, + }, + } + ), + ) return self._avail_backups - + def reset_instance_root_password(self, root_password=None): rpass = root_password if not rpass: rpass = Instance.generate_root_password() params = { - 'password': rpass, + "password": rpass, } - - self._client.post('{}/password'.format(Instance.api_endpoint), model=self, data=params) - + self._client.post( + "{}/password".format(Instance.api_endpoint), model=self, data=params + ) + def transfer_year_month(self, year, month): """ Get per-linode transfer for specified month """ - result = self._client.get('{}/transfer/{}/{}'.format(Instance.api_endpoint, year, month), model=self) + result = self._client.get( + "{}/transfer/{}/{}".format(Instance.api_endpoint, year, month), + model=self, + ) return MappedObject(**result) - @property def transfer(self): """ Get per-linode transfer """ - if not hasattr(self, '_transfer'): - result = self._client.get("{}/transfer".format(Instance.api_endpoint), model=self) + if not hasattr(self, "_transfer"): + result = self._client.get( + "{}/transfer".format(Instance.api_endpoint), model=self + ) - if not 'used' in result: - raise UnexpectedResponseError('Unexpected response when getting Transfer Pool!') + if not "used" in result: + raise UnexpectedResponseError( + "Unexpected response when getting Transfer Pool!" + ) mapped = MappedObject(**result) - setattr(self, '_transfer', mapped) + setattr(self, "_transfer", mapped) return self._transfer def _populate(self, json): if json is not None: # fixes ipv4 and ipv6 attribute of json to make base._populate work - if 'ipv4' in json and 'address' in json['ipv4']: - json['ipv4']['id'] = json['ipv4']['address'] - if 'ipv6' in json and isinstance(json['ipv6'], list): - for j in json['ipv6']: - j['id'] = j['range'] + if "ipv4" in json and "address" in json["ipv4"]: + json["ipv4"]["id"] = json["ipv4"]["address"] + if "ipv6" in json and isinstance(json["ipv6"], list): + for j in json["ipv6"]: + j["id"] = j["range"] Base._populate(self, json) def invalidate(self): - """ Clear out cached properties """ - if hasattr(self, '_avail_backups'): + """Clear out cached properties""" + if hasattr(self, "_avail_backups"): del self._avail_backups - if hasattr(self, '_ips'): + if hasattr(self, "_ips"): del self._ips - if hasattr(self, '_transfer'): + if hasattr(self, "_transfer"): del self._transfer Base.invalidate(self) def boot(self, config=None): - resp = self._client.post("{}/boot".format(Instance.api_endpoint), model=self, data={'config_id': config.id} if config else None) + resp = self._client.post( + "{}/boot".format(Instance.api_endpoint), + model=self, + data={"config_id": config.id} if config else None, + ) - if 'error' in resp: + if "error" in resp: return False return True def shutdown(self): - resp = self._client.post("{}/shutdown".format(Instance.api_endpoint), model=self) + resp = self._client.post( + "{}/shutdown".format(Instance.api_endpoint), model=self + ) - if 'error' in resp: + if "error" in resp: return False return True def reboot(self): - resp = self._client.post("{}/reboot".format(Instance.api_endpoint), model=self) + resp = self._client.post( + "{}/reboot".format(Instance.api_endpoint), model=self + ) - if 'error' in resp: + if "error" in resp: return False return True @@ -482,9 +541,11 @@ def resize(self, new_type, **kwargs): } params.update(kwargs) - resp = self._client.post("{}/resize".format(Instance.api_endpoint), model=self, data=params) + resp = self._client.post( + "{}/resize".format(Instance.api_endpoint), model=self, data=params + ) - if 'error' in resp: + if "error" in resp: return False return True @@ -492,13 +553,15 @@ def resize(self, new_type, **kwargs): def generate_root_password(): def _func(value): if sys.version_info[0] < 3: - value = int(value.encode('hex'), 16) + value = int(value.encode("hex"), 16) return value - password = ''.join([ - PASSWORD_CHARS[_func(c) % len(PASSWORD_CHARS)] - for c in urandom(randint(50, 110)) - ]) + password = "".join( + [ + PASSWORD_CHARS[_func(c) % len(PASSWORD_CHARS)] + for c in urandom(randint(50, 110)) + ] + ) # ensure the generated password is not too long if len(password) > 110: @@ -507,8 +570,15 @@ def _func(value): return password # create derived objects - def config_create(self, kernel=None, label=None, devices=[], disks=[], - volumes=[], **kwargs): + def config_create( + self, + kernel=None, + label=None, + devices=[], + disks=[], + volumes=[], + **kwargs, + ): """ Creates a Linode Config with the given attributes. @@ -525,15 +595,21 @@ def config_create(self, kernel=None, label=None, devices=[], disks=[], :returns: A new Linode Config """ # needed here to avoid circular imports - from .volume import Volume # pylint: disable=import-outside-toplevel - - hypervisor_prefix = 'sd' if self.hypervisor == 'kvm' else 'xvd' - device_names = [hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8)] - device_map = {device_names[i]: None for i in range(0, len(device_names))} + from .volume import Volume # pylint: disable=import-outside-toplevel + + hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" + device_names = [ + hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8) + ] + device_map = { + device_names[i]: None for i in range(0, len(device_names)) + } if devices and (disks or volumes): - raise ValueError('You may not call config_create with "devices" and ' - 'either of "disks" or "volumes" specified!') + raise ValueError( + 'You may not call config_create with "devices" and ' + 'either of "disks" or "volumes" specified!' + ) if not devices: if not isinstance(disks, list): @@ -560,36 +636,53 @@ def config_create(self, kernel=None, label=None, devices=[], disks=[], devices.append(Volume(self._client, int(v))) if not devices: - raise ValueError('Must include at least one disk or volume!') + raise ValueError("Must include at least one disk or volume!") for i, d in enumerate(devices): if d is None: pass elif isinstance(d, Disk): - device_map[device_names[i]] = {'disk_id': d.id } + device_map[device_names[i]] = {"disk_id": d.id} elif isinstance(d, Volume): - device_map[device_names[i]] = {'volume_id': d.id } + device_map[device_names[i]] = {"volume_id": d.id} else: - raise TypeError('Disk or Volume expected!') + raise TypeError("Disk or Volume expected!") params = { - 'kernel': kernel.id if issubclass(type(kernel), Base) else kernel, - 'label': label if label else "{}_config_{}".format(self.label, len(self.configs)), - 'devices': device_map, + "kernel": kernel.id if issubclass(type(kernel), Base) else kernel, + "label": label + if label + else "{}_config_{}".format(self.label, len(self.configs)), + "devices": device_map, } params.update(kwargs) - result = self._client.post("{}/configs".format(Instance.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/configs".format(Instance.api_endpoint), model=self, data=params + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating config!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating config!", json=result + ) - c = Config(self._client, result['id'], self.id, result) + c = Config(self._client, result["id"], self.id, result) return c - def disk_create(self, size, label=None, filesystem=None, read_only=False, image=None, - root_pass=None, authorized_keys=None, authorized_users=None, stackscript=None, **stackscript_args): + def disk_create( + self, + size, + label=None, + filesystem=None, + read_only=False, + image=None, + root_pass=None, + authorized_keys=None, + authorized_users=None, + stackscript=None, + **stackscript_args, + ): """ Creates a new Disk for this Instance. @@ -617,7 +710,7 @@ def disk_create(self, size, label=None, filesystem=None, read_only=False, image= gen_pass = None if image and not root_pass: - gen_pass = Instance.generate_root_password() + gen_pass = Instance.generate_root_password() root_pass = gen_pass authorized_keys = load_and_validate_keys(authorized_keys) @@ -626,32 +719,42 @@ def disk_create(self, size, label=None, filesystem=None, read_only=False, image= label = "My {} Disk".format(image.label) params = { - 'size': size, - 'label': label if label else "{}_disk_{}".format(self.label, len(self.disks)), - 'read_only': read_only, - 'filesystem': filesystem, - 'authorized_keys': authorized_keys, - 'authorized_users': authorized_users, + "size": size, + "label": label + if label + else "{}_disk_{}".format(self.label, len(self.disks)), + "read_only": read_only, + "filesystem": filesystem, + "authorized_keys": authorized_keys, + "authorized_users": authorized_users, } if image: - params.update({ - 'image': image.id if issubclass(type(image), Base) else image, - 'root_pass': root_pass, - }) + params.update( + { + "image": image.id + if issubclass(type(image), Base) + else image, + "root_pass": root_pass, + } + ) if stackscript: - params['stackscript_id'] = stackscript.id + params["stackscript_id"] = stackscript.id if stackscript_args: - params['stackscript_data'] = stackscript_args + params["stackscript_data"] = stackscript_args - result = self._client.post("{}/disks".format(Instance.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/disks".format(Instance.api_endpoint), model=self, data=params + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating disk!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating disk!", json=result + ) - d = Disk(self._client, result['id'], self.id, result) + d = Disk(self._client, result["id"], self.id, result) if gen_pass: return d, gen_pass @@ -666,7 +769,9 @@ def enable_backups(self): .. _Backups Page: https://www.linode.com/backups """ - self._client.post("{}/backups/enable".format(Instance.api_endpoint), model=self) + self._client.post( + "{}/backups/enable".format(Instance.api_endpoint), model=self + ) self.invalidate() return True @@ -676,22 +781,29 @@ def cancel_backups(self): including any snapshots that have been taken. This cannot be undone, but Backups can be re-enabled at a later date. """ - self._client.post("{}/backups/cancel".format(Instance.api_endpoint), model=self) + self._client.post( + "{}/backups/cancel".format(Instance.api_endpoint), model=self + ) self.invalidate() return True def snapshot(self, label=None): - result = self._client.post("{}/backups".format(Instance.api_endpoint), model=self, - data={ "label": label }) + result = self._client.post( + "{}/backups".format(Instance.api_endpoint), + model=self, + data={"label": label}, + ) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response taking snapshot!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response taking snapshot!", json=result + ) # so the changes show up the next time they're accessed - if hasattr(self, '_avail_backups'): + if hasattr(self, "_avail_backups"): del self._avail_backups - b = Backup(self._client, result['id'], self.id, result) + b = Backup(self._client, result["id"], self.id, result) return b def ip_allocate(self, public=False): @@ -714,13 +826,15 @@ def ip_allocate(self, public=False): data={ "type": "ipv4", "public": public, - }) + }, + ) - if not 'address' in result: - raise UnexpectedResponseError('Unexpected response allocating IP!', - json=result) + if not "address" in result: + raise UnexpectedResponseError( + "Unexpected response allocating IP!", json=result + ) - i = IPAddress(self._client, result['address'], result) + i = IPAddress(self._client, result["address"], result) return i def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): @@ -752,16 +866,20 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): authorized_keys = load_and_validate_keys(authorized_keys) params = { - 'image': image.id if issubclass(type(image), Base) else image, - 'root_pass': root_pass, - 'authorized_keys': authorized_keys, - } + "image": image.id if issubclass(type(image), Base) else image, + "root_pass": root_pass, + "authorized_keys": authorized_keys, + } params.update(kwargs) - result = self._client.post('{}/rebuild'.format(Instance.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/rebuild".format(Instance.api_endpoint), model=self, data=params + ) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response issuing rebuild!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response issuing rebuild!", json=result + ) # update ourself with the newly-returned information self._populate(result) @@ -773,12 +891,20 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): def rescue(self, *disks): if disks: - disks = { x: { 'disk_id': y } for x,y in zip(('sda','sdb','sdc','sdd','sde','sdf','sdg'), disks) } + disks = { + x: {"disk_id": y} + for x, y in zip( + ("sda", "sdb", "sdc", "sdd", "sde", "sdf", "sdg"), disks + ) + } else: - disks=None + disks = None - result = self._client.post('{}/rescue'.format(Instance.api_endpoint), model=self, - data={ "devices": disks }) + result = self._client.post( + "{}/rescue".format(Instance.api_endpoint), + model=self, + data={"devices": disks}, + ) return result @@ -786,7 +912,7 @@ def kvmify(self): """ Converts this linode to KVM from Xen """ - self._client.post('{}/kvmify'.format(Instance.api_endpoint), model=self) + self._client.post("{}/kvmify".format(Instance.api_endpoint), model=self) return True @@ -795,11 +921,11 @@ def mutate(self, allow_auto_disk_resize=True): Upgrades this Instance to the latest generation type """ - params = { - "allow_auto_disk_resize": allow_auto_disk_resize - } + params = {"allow_auto_disk_resize": allow_auto_disk_resize} - self._client.post('{}/mutate'.format(Instance.api_endpoint), model=self, data=params) + self._client.post( + "{}/mutate".format(Instance.api_endpoint), model=self, data=params + ) return True @@ -810,63 +936,97 @@ def initiate_migration(self, region=None, upgrade=None): """ params = { "region": region.id if issubclass(type(region), Base) else region, - "upgrade": upgrade + "upgrade": upgrade, } util.drop_null_keys(params) - self._client.post('{}/migrate'.format(Instance.api_endpoint), model=self, data=params) + self._client.post( + "{}/migrate".format(Instance.api_endpoint), model=self, data=params + ) def firewalls(self): """ View Firewall information for Firewalls associated with this Linode. """ - from linode_api4.objects import Firewall # pylint: disable=import-outside-toplevel - - result = self._client.get('{}/firewalls'.format(Instance.api_endpoint), model=self) + from linode_api4.objects import ( # pylint: disable=import-outside-toplevel + Firewall, + ) - return [Firewall(self._client, firewall["id"]) for firewall in result["data"]] + result = self._client.get( + "{}/firewalls".format(Instance.api_endpoint), model=self + ) + return [ + Firewall(self._client, firewall["id"]) + for firewall in result["data"] + ] def nodebalancers(self): """ View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. """ - from linode_api4.objects import NodeBalancer # pylint: disable=import-outside-toplevel + from linode_api4.objects import ( # pylint: disable=import-outside-toplevel + NodeBalancer, + ) - result = self._client.get('{}/nodebalancers'.format(Instance.api_endpoint), model=self) + result = self._client.get( + "{}/nodebalancers".format(Instance.api_endpoint), model=self + ) - return [NodeBalancer(self._client, nodebalancer["id"]) for nodebalancer in result["data"]] + return [ + NodeBalancer(self._client, nodebalancer["id"]) + for nodebalancer in result["data"] + ] def volumes(self): """ View Block Storage Volumes attached to this Linode. """ - from linode_api4.objects import Volume # pylint: disable=import-outside-toplevel + from linode_api4.objects import ( # pylint: disable=import-outside-toplevel + Volume, + ) - result = self._client.get('{}/volumes'.format(Instance.api_endpoint), model=self) + result = self._client.get( + "{}/volumes".format(Instance.api_endpoint), model=self + ) return [Volume(self._client, volume["id"]) for volume in result["data"]] - def clone(self, to_linode=None, region=None, service=None, configs=[], disks=[], - label=None, group=None, with_backups=None): - """ Clones this linode into a new linode or into a new linode in the given region """ + def clone( + self, + to_linode=None, + region=None, + service=None, + configs=[], + disks=[], + label=None, + group=None, + with_backups=None, + ): + """Clones this linode into a new linode or into a new linode in the given region""" if to_linode and region: - raise ValueError('You may only specify one of "to_linode" and "region"') + raise ValueError( + 'You may only specify one of "to_linode" and "region"' + ) if region and not service: raise ValueError('Specifying a region requires a "service" as well') - if not isinstance(configs, list) and not isinstance(configs, PaginatedList): + if not isinstance(configs, list) and not isinstance( + configs, PaginatedList + ): configs = [configs] if not isinstance(disks, list) and not isinstance(disks, PaginatedList): disks = [disks] - cids = [ c.id if issubclass(type(c), Base) else c for c in configs ] - dids = [ d.id if issubclass(type(d), Base) else d for d in disks ] + cids = [c.id if issubclass(type(c), Base) else c for c in configs] + dids = [d.id if issubclass(type(d), Base) else d for d in disks] params = { - "linode_id": to_linode.id if issubclass(type(to_linode), Base) else to_linode, + "linode_id": to_linode.id + if issubclass(type(to_linode), Base) + else to_linode, "region": region.id if issubclass(type(region), Base) else region, "type": service.id if issubclass(type(service), Base) else service, "configs": cids if cids else None, @@ -876,12 +1036,16 @@ def clone(self, to_linode=None, region=None, service=None, configs=[], disks=[], "with_backups": with_backups, } - result = self._client.post('{}/clone'.format(Instance.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/clone".format(Instance.api_endpoint), model=self, data=params + ) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response cloning Instance!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response cloning Instance!", json=result + ) - l = Instance(self._client, result['id'], result) + l = Instance(self._client, result["id"], result) return l @property @@ -890,7 +1054,9 @@ def stats(self): Returns the JSON stats for this Instance """ # TODO - this would be nicer if we formatted the stats - return self._client.get('{}/stats'.format(Instance.api_endpoint), model=self) + return self._client.get( + "{}/stats".format(Instance.api_endpoint), model=self + ) def stats_for(self, dt): """ @@ -898,8 +1064,11 @@ def stats_for(self, dt): """ # TODO - this would be nicer if we formatted the stats if not isinstance(dt, datetime): - raise TypeError('stats_for requires a datetime object!') - return self._client.get('{}/stats/{}'.format(Instance.api_endpoint, dt.strftime('%Y/%m')), model=self) + raise TypeError("stats_for requires a datetime object!") + return self._client.get( + "{}/stats/{}".format(Instance.api_endpoint, dt.strftime("%Y/%m")), + model=self, + ) class UserDefinedFieldType(Enum): @@ -907,7 +1076,8 @@ class UserDefinedFieldType(Enum): select_one = 2 select_many = 3 -class UserDefinedField(): + +class UserDefinedField: def __init__(self, name, label, example, field_type, choices=None): self.name = name self.label = label @@ -916,10 +1086,13 @@ def __init__(self, name, label, example, field_type, choices=None): self.choices = choices def __repr__(self): - return "{}({}): {}".format(self.label, self.field_type.name, self.example) + return "{}({}): {}".format( + self.label, self.field_type.name, self.example + ) + class StackScript(Base): - api_endpoint = '/linode/stackscripts/{id}' + api_endpoint = "/linode/stackscripts/{id}" properties = { "user_defined_fields": Property(), "label": Property(mutable=True, filterable=True), @@ -930,7 +1103,9 @@ class StackScript(Base): "created": Property(is_datetime=True), "deployments_active": Property(), "script": Property(mutable=True), - "images": Property(mutable=True, filterable=True), # TODO make slug_relationship + "images": Property( + mutable=True, filterable=True + ), # TODO make slug_relationship "deployments_total": Property(), "description": Property(mutable=True, filterable=True), "updated": Property(is_datetime=True), @@ -947,23 +1122,28 @@ def _populate(self, json): for udf in self.user_defined_fields: t = UserDefinedFieldType.text choices = None - if hasattr(udf, 'oneof'): + if hasattr(udf, "oneof"): t = UserDefinedFieldType.select_one - choices = udf.oneof.split(',') - elif hasattr(udf, 'manyof'): + choices = udf.oneof.split(",") + elif hasattr(udf, "manyof"): t = UserDefinedFieldType.select_many - choices = udf.manyof.split(',') - - mapped_udfs.append(UserDefinedField(udf.name, - udf.label if hasattr(udf, 'label') else None, - udf.example if hasattr(udf, 'example') else None, - t, choices=choices)) - - self._set('user_defined_fields', mapped_udfs) - ndist = [ Image(self._client, d) for d in self.images ] - self._set('images', ndist) + choices = udf.manyof.split(",") + + mapped_udfs.append( + UserDefinedField( + udf.name, + udf.label if hasattr(udf, "label") else None, + udf.example if hasattr(udf, "example") else None, + t, + choices=choices, + ) + ) + + self._set("user_defined_fields", mapped_udfs) + ndist = [Image(self._client, d) for d in self.images] + self._set("images", ndist) def _serialize(self): dct = Base._serialize(self) - dct['images'] = [ d.id for d in self.images ] + dct["images"] = [d.id for d in self.images] return dct diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index bdc87035a..1396dbda8 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -1,12 +1,20 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( - Base, DerivedBase, Property, Region, Type, Instance, MappedObject + Base, + DerivedBase, + Instance, + MappedObject, + Property, + Region, + Type, ) + class KubeVersion(Base): """ A KubeVersion is a version of Kubernetes that can be deployed on LKE. """ + api_endpoint = "/lke/versions/{id}" properties = { @@ -14,18 +22,21 @@ class KubeVersion(Base): } -class LKENodePoolNode(): +class LKENodePoolNode: """ AN LKE Node Pool Node is a helper class that is used to populate the "nodes" array of an LKE Node Pool, and set up an automatic relationship with the Linode Instance the Node represented. """ + def __init__(self, client, json): """ Creates this NodePoolNode """ #: The ID of this Node Pool Node - self.id = json.get("id") # why do these have an ID if they don't have an endpoint of their own? + self.id = json.get( + "id" + ) # why do these have an ID if they don't have an endpoint of their own? #: The ID of the Linode Instance this Node represents self.instance_id = json.get("instance_id") @@ -42,8 +53,9 @@ class LKENodePool(DerivedBase): An LKE Node Pool describes a pool of Linode Instances that exist within an LKE Cluster. """ + api_endpoint = "/lke/clusters/{cluster_id}/pools/{id}" - derived_url_path = 'pools' + derived_url_path = "pools" parent_id_name = "cluster_id" properties = { @@ -52,7 +64,9 @@ class LKENodePool(DerivedBase): "type": Property(slug_relationship=Type), "disks": Property(), "count": Property(mutable=True), - "nodes": Property(volatile=True), # this is formatted in _populate below + "nodes": Property( + volatile=True + ), # this is formatted in _populate below "autoscaler": Property(), "tags": Property(mutable=True), } @@ -75,7 +89,9 @@ def recycle(self): Completing this operation may take several minutes. This operation will cause all local data on Linode Instances in this pool to be lost. """ - self._client.post("{}/recycle".format(LKENodePool.api_endpoint), model=self) + self._client.post( + "{}/recycle".format(LKENodePool.api_endpoint), model=self + ) self.invalidate() @@ -83,18 +99,19 @@ class LKECluster(Base): """ An LKE Cluster is a single k8s cluster deployed via Linode Kubernetes Engine. """ + api_endpoint = "/lke/clusters/{id}" properties = { - "id": Property(identifier=True), - "created": Property(is_datetime=True), - "label": Property(mutable=True), - "tags": Property(mutable=True), - "updated": Property(is_datetime=True), - "region": Property(slug_relationship=Region), - "k8s_version": Property(slug_relationship=KubeVersion), - "pools": Property(derived_class=LKENodePool), - "control_plane": Property(), + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "label": Property(mutable=True), + "tags": Property(mutable=True), + "updated": Property(is_datetime=True), + "region": Property(slug_relationship=Region), + "k8s_version": Property(slug_relationship=KubeVersion), + "pools": Property(derived_class=LKENodePool), + "control_plane": Property(), } @property @@ -106,9 +123,11 @@ def api_endpoints(self): # have IDs and can't be retrieved on their own, and it doesn't accept normal # pagination properties, so we're converting this to a list of strings. if not hasattr(self, "_api_endpoints"): - results = self._client.get("{}/api-endpoints".format(LKECluster.api_endpoint), model=self) + results = self._client.get( + "{}/api-endpoints".format(LKECluster.api_endpoint), model=self + ) - self._api_endpoints = [MappedObject(**c) for c in results["data"]] # pylint: disable=attribute-defined-outside-init + self._api_endpoints = [MappedObject(**c) for c in results["data"]] return self._api_endpoints @@ -134,10 +153,11 @@ def kubeconfig(self): cluster; during that time this request may fail. """ if not hasattr(self, "_kubeconfig"): - result = self._client.get("{}/kubeconfig".format(LKECluster.api_endpoint), model=self) - - self._kubeconfig = result["kubeconfig"] # pylint: disable=attribute-defined-outside-init + result = self._client.get( + "{}/kubeconfig".format(LKECluster.api_endpoint), model=self + ) + self._kubeconfig = result["kubeconfig"] return self._kubeconfig @@ -161,36 +181,46 @@ def node_pool_create(self, node_type, node_count, **kwargs): } params.update(kwargs) - result = self._client.post("{}/pools".format(LKECluster.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/pools".format(LKECluster.api_endpoint), model=self, data=params + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating node pool!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating node pool!", json=result + ) return LKENodePool(self._client, result["id"], self.id, result) - + def cluster_dashboard_url_view(self): """ Get a Kubernetes Dashboard access URL for this Cluster. """ - result = self._client.get('{}/dashboard'.format(LKECluster.api_endpoint), model=self) + result = self._client.get( + "{}/dashboard".format(LKECluster.api_endpoint), model=self + ) return result["url"] - + def kubeconfig_delete(self): """ Delete and regenerate the Kubeconfig file for a Cluster. """ - self._client.delete('{}/kubeconfig'.format(LKECluster.api_endpoint), model=self) + self._client.delete( + "{}/kubeconfig".format(LKECluster.api_endpoint), model=self + ) def node_view(self, nodeId): """ Get a specific Node Pool by ID. """ - node = self._client.get('{}/nodes/{}'.format(LKECluster.api_endpoint, nodeId), model=self) + node = self._client.get( + "{}/nodes/{}".format(LKECluster.api_endpoint, nodeId), model=self + ) return LKENodePoolNode(self._client, node) @@ -199,32 +229,43 @@ def node_delete(self, nodeId): Delete a specific Node from a Node Pool. """ - self._client.delete('{}/nodes/{}'.format(LKECluster.api_endpoint, nodeId), model=self) + self._client.delete( + "{}/nodes/{}".format(LKECluster.api_endpoint, nodeId), model=self + ) def node_recycle(self, nodeId): """ Recycle a specific Node from an LKE cluster. """ - self._client.post('{}/nodes/{}/recycle'.format(LKECluster.api_endpoint, nodeId), model=self) + self._client.post( + "{}/nodes/{}/recycle".format(LKECluster.api_endpoint, nodeId), + model=self, + ) def cluster_nodes_recycle(self): """ Recycles all nodes in all pools of a designated Kubernetes Cluster. """ - self._client.post('{}/recycle'.format(LKECluster.api_endpoint), model=self) + self._client.post( + "{}/recycle".format(LKECluster.api_endpoint), model=self + ) def cluster_regenerate(self): """ Regenerate the Kubeconfig file and/or the service account token for a Cluster. """ - self._client.post('{}/regenerate'.format(LKECluster.api_endpoint), model=self) + self._client.post( + "{}/regenerate".format(LKECluster.api_endpoint), model=self + ) def service_token_delete(self): """ Delete and regenerate the service account token for a Cluster. """ - self._client.delete('{}/servicetoken'.format(LKECluster.api_endpoint), model=self) \ No newline at end of file + self._client.delete( + "{}/servicetoken".format(LKECluster.api_endpoint), model=self + ) diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py index ceeac219c..35b159380 100644 --- a/linode_api4/objects/longview.py +++ b/linode_api4/objects/longview.py @@ -2,10 +2,9 @@ class LongviewClient(Base): + api_endpoint = "/longview/clients/{id}" - api_endpoint = '/longview/clients/{id}' - - properties= { + properties = { "id": Property(identifier=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), @@ -17,10 +16,10 @@ class LongviewClient(Base): class LongviewSubscription(Base): - api_endpoint = 'longview/subscriptions/{id}' + api_endpoint = "longview/subscriptions/{id}" properties = { "id": Property(identifier=True), "label": Property(), "clients_included": Property(), - "price": Property() + "price": Property(), } diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index bd74bf945..faa7e5afa 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -3,28 +3,28 @@ class IPv6Pool(Base): - api_endpoint = '/networking/ipv6/pools/{}' - id_attribute = 'range' + api_endpoint = "/networking/ipv6/pools/{}" + id_attribute = "range" properties = { - 'range': Property(identifier=True), - 'region': Property(slug_relationship=Region, filterable=True), + "range": Property(identifier=True), + "region": Property(slug_relationship=Region, filterable=True), } class IPv6Range(Base): - api_endpoint = '/networking/ipv6/ranges/{}' - id_attribute = 'range' + api_endpoint = "/networking/ipv6/ranges/{}" + id_attribute = "range" properties = { - 'range': Property(identifier=True), - 'region': Property(slug_relationship=Region, filterable=True), + "range": Property(identifier=True), + "region": Property(slug_relationship=Region, filterable=True), } class IPAddress(Base): - api_endpoint = '/networking/ips/{address}' - id_attribute = 'address' + api_endpoint = "/networking/ips/{address}" + id_attribute = "address" properties = { "address": Property(identifier=True), @@ -40,9 +40,10 @@ class IPAddress(Base): @property def linode(self): - from .linode import Instance # pylint: disable-all - if not hasattr(self, '_linode'): - self._set('_linode', Instance(self._client, self.linode_id)) + from .linode import Instance # pylint: disable-all + + if not hasattr(self, "_linode"): + self._set("_linode", Instance(self._client, self.linode_id)) return self._linode def to(self, linode): @@ -51,11 +52,11 @@ def to(self, linode): of that context. It's used to cleanly build an IP Assign request with pretty python syntax. """ - from .linode import Instance # pylint: disable-all + from .linode import Instance # pylint: disable-all + if not isinstance(linode, Instance): raise ValueError("IP Address can only be assigned to a Linode!") - return { "address": self.address, "linode_id": linode.id } - + return {"address": self.address, "linode_id": linode.id} class VLAN(Base): @@ -63,27 +64,28 @@ class VLAN(Base): .. note:: At this time, the Linode API only supports listing VLANs. .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. """ - api_endpoint = '/networking/vlans/{}' - id_attribute = 'label' + + api_endpoint = "/networking/vlans/{}" + id_attribute = "label" properties = { - 'label': Property(identifier=True), - 'created': Property(is_datetime=True), - 'linodes': Property(filterable=True), - 'region': Property(slug_relationship=Region, filterable=True) + "label": Property(identifier=True), + "created": Property(is_datetime=True), + "linodes": Property(filterable=True), + "region": Property(slug_relationship=Region, filterable=True), } class FirewallDevice(DerivedBase): - api_endpoint = '/networking/firewalls/{firewall_id}/devices/{id}' - derived_url_path = 'devices' - parent_id_name = 'firewall_id' + api_endpoint = "/networking/firewalls/{firewall_id}/devices/{id}" + derived_url_path = "devices" + parent_id_name = "firewall_id" properties = { - 'created': Property(filterable=True, is_datetime=True), - 'updated': Property(filterable=True, is_datetime=True), - 'entity': Property(), - 'id': Property(identifier=True) + "created": Property(filterable=True, is_datetime=True), + "updated": Property(filterable=True, is_datetime=True), + "entity": Property(), + "id": Property(identifier=True), } @@ -95,23 +97,26 @@ class Firewall(Base): api_endpoint = "/networking/firewalls/{id}" properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True, filterable=True), - 'tags': Property(mutable=True, filterable=True), - 'status': Property(mutable=True), - 'created': Property(filterable=True, is_datetime=True), - 'updated': Property(filterable=True, is_datetime=True), - 'devices': Property(derived_class=FirewallDevice), - 'rules': Property(), + "id": Property(identifier=True), + "label": Property(mutable=True, filterable=True), + "tags": Property(mutable=True, filterable=True), + "status": Property(mutable=True), + "created": Property(filterable=True, is_datetime=True), + "updated": Property(filterable=True, is_datetime=True), + "devices": Property(derived_class=FirewallDevice), + "rules": Property(), } + def update_rules(self, rules): """ Sets the JSON rules for this Firewall """ - self._client.put('{}/rules'.format(self.api_endpoint), model=self, data=rules) + self._client.put( + "{}/rules".format(self.api_endpoint), model=self, data=rules + ) self.invalidate() - def device_create(self, id, type='linode', **kwargs): + def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall @@ -122,16 +127,20 @@ def device_create(self, id, type='linode', **kwargs): :type type: str """ params = { - 'id': id, - 'type': type, + "id": id, + "type": type, } params.update(kwargs) - result = self._client.post("{}/devices".format(Firewall.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/devices".format(Firewall.api_endpoint), model=self, data=params + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating device!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating device!", json=result + ) - c = FirewallDevice(self._client, result['id'], self.id, result) + c = FirewallDevice(self._client, result["id"], self.id, result) return c diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 9924843d4..f228469c8 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -6,20 +6,22 @@ class NodeBalancerNode(DerivedBase): - api_endpoint = '/nodebalancers/{nodebalancer_id}/configs/{config_id}/nodes/{id}' - derived_url_path = 'nodes' - parent_id_name='config_id' + api_endpoint = ( + "/nodebalancers/{nodebalancer_id}/configs/{config_id}/nodes/{id}" + ) + derived_url_path = "nodes" + parent_id_name = "config_id" properties = { - 'id': Property(identifier=True), - 'config_id': Property(identifier=True), - 'nodebalancer_id': Property(identifier=True), + "id": Property(identifier=True), + "config_id": Property(identifier=True), + "nodebalancer_id": Property(identifier=True), "label": Property(mutable=True), "address": Property(mutable=True), "weight": Property(mutable=True), "mode": Property(mutable=True), "status": Property(), - 'tags': Property(mutable=True), + "tags": Property(mutable=True), } def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None): @@ -28,8 +30,10 @@ def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None): has a parent itself. """ if not nodebalancer_id and not isinstance(parent_id, tuple): - raise ValueError('NodeBalancerNode must either be created with a nodebalancer_id or a tuple of ' - '(config_id, nodebalancer_id) for parent_id!') + raise ValueError( + "NodeBalancerNode must either be created with a nodebalancer_id or a tuple of " + "(config_id, nodebalancer_id) for parent_id!" + ) if isinstance(parent_id, tuple): nodebalancer_id = parent_id[1] @@ -37,17 +41,17 @@ def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None): DerivedBase.__init__(self, client, id, parent_id, json=json) - self._set('nodebalancer_id', nodebalancer_id) + self._set("nodebalancer_id", nodebalancer_id) class NodeBalancerConfig(DerivedBase): - api_endpoint = '/nodebalancers/{nodebalancer_id}/configs/{id}' - derived_url_path = 'configs' - parent_id_name='nodebalancer_id' + api_endpoint = "/nodebalancers/{nodebalancer_id}/configs/{id}" + derived_url_path = "configs" + parent_id_name = "nodebalancer_id" properties = { - 'id': Property(identifier=True), - 'nodebalancer_id': Property(identifier=True), + "id": Property(identifier=True), + "nodebalancer_id": Property(identifier=True), "port": Property(mutable=True), "protocol": Property(mutable=True), "algorithm": Property(mutable=True), @@ -65,7 +69,7 @@ class NodeBalancerConfig(DerivedBase): "ssl_fingerprint": Property(), "cipher_suite": Property(mutable=True), "nodes_status": Property(), - 'proxy_protocol': Property(mutable=True), + "proxy_protocol": Property(mutable=True), } @property @@ -74,11 +78,19 @@ def nodes(self): This is a special derived_class relationship because NodeBalancerNode is the only api object that requires two parent_ids """ - if not hasattr(self, '_nodes'): - base_url = "{}/{}".format(NodeBalancerConfig.api_endpoint, NodeBalancerNode.derived_url_path) - result = self._client._get_objects(base_url, NodeBalancerNode, model=self, parent_id=(self.id, self.nodebalancer_id)) - - self._set('_nodes', result) + if not hasattr(self, "_nodes"): + base_url = "{}/{}".format( + NodeBalancerConfig.api_endpoint, + NodeBalancerNode.derived_url_path, + ) + result = self._client._get_objects( + base_url, + NodeBalancerNode, + model=self, + parent_id=(self.id, self.nodebalancer_id), + ) + + self._set("_nodes", result) return self._nodes @@ -89,14 +101,22 @@ def node_create(self, label, address, **kwargs): } params.update(kwargs) - result = self._client.post("{}/nodes".format(NodeBalancerConfig.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/nodes".format(NodeBalancerConfig.api_endpoint), + model=self, + data=params, + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating node!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating node!", json=result + ) # this is three levels deep, so we need a special constructor - n = NodeBalancerNode(self._client, result['id'], self.id, self.nodebalancer_id, result) + n = NodeBalancerNode( + self._client, result["id"], self.id, self.nodebalancer_id, result + ) return n def load_ssl_data(self, cert_file, key_file): @@ -124,40 +144,46 @@ def load_ssl_data(self, cert_file, key_file): # through linode.objects.Base, and pylint isn't privy if os.path.isfile(os.path.expanduser(cert_file)): with open(os.path.expanduser(cert_file)) as f: - self.ssl_cert = f.read() # pylint: disable=attribute-defined-outside-init + self.ssl_cert = f.read() if os.path.isfile(os.path.expanduser(key_file)): with open(os.path.expanduser(key_file)) as f: - self.ssl_key = f.read() # pylint: disable=attribute-defined-outside-init + self.ssl_key = f.read() class NodeBalancer(Base): - api_endpoint = '/nodebalancers/{id}' + api_endpoint = "/nodebalancers/{id}" properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True), - 'hostname': Property(), - 'client_conn_throttle': Property(mutable=True), - 'status': Property(), - 'created': Property(is_datetime=True), - 'updated': Property(is_datetime=True), - 'ipv4': Property(relationship=IPAddress), - 'ipv6': Property(), - 'region': Property(slug_relationship=Region, filterable=True), - 'configs': Property(derived_class=NodeBalancerConfig), + "id": Property(identifier=True), + "label": Property(mutable=True), + "hostname": Property(), + "client_conn_throttle": Property(mutable=True), + "status": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "ipv4": Property(relationship=IPAddress), + "ipv6": Property(), + "region": Property(slug_relationship=Region, filterable=True), + "configs": Property(derived_class=NodeBalancerConfig), } # create derived objects def config_create(self, label=None, **kwargs): params = kwargs if label: - params['label'] = label + params["label"] = label - result = self._client.post("{}/configs".format(NodeBalancer.api_endpoint), model=self, data=params) + result = self._client.post( + "{}/configs".format(NodeBalancer.api_endpoint), + model=self, + data=params, + ) self.invalidate() - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response creating config!', json=result) + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating config!", json=result + ) - c = NodeBalancerConfig(self._client, result['id'], self.id, result) + c = NodeBalancerConfig(self._client, result["id"], self.id, result) return c diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 9abcfefde..b2be22dfe 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -5,14 +5,15 @@ class ObjectStorageCluster(Base): """ A cluster where Object Storage is available. """ - api_endpoint = '/object-storage/clusters/{id}' + + api_endpoint = "/object-storage/clusters/{id}" properties = { - 'id': Property(identifier=True), - 'region': Property(slug_relationship=Region), - 'status': Property(), - 'domain': Property(), - 'static_site_domain': Property(), + "id": Property(identifier=True), + "region": Property(slug_relationship=Region), + "status": Property(), + "domain": Property(), + "static_site_domain": Property(), } @@ -20,11 +21,12 @@ class ObjectStorageKeys(Base): """ A keypair that allows third-party applications to access Linode Object Storage. """ - api_endpoint = '/object-storage/keys/{id}' + + api_endpoint = "/object-storage/keys/{id}" properties = { - 'id': Property(identifier=True), - 'label': Property(mutable=True), - 'access_key': Property(), - 'secret_key': Property(), + "id": Property(identifier=True), + "label": Property(mutable=True), + "access_key": Property(), + "secret_key": Property(), } diff --git a/linode_api4/objects/profile.py b/linode_api4/objects/profile.py index 7c64340e5..85e5baeb3 100644 --- a/linode_api4/objects/profile.py +++ b/linode_api4/objects/profile.py @@ -33,29 +33,29 @@ class WhitelistEntry(Base): api_endpoint = "/profile/whitelist/{id}" properties = { - 'id': Property(identifier=True), - 'address': Property(), - 'netmask': Property(), - 'note': Property(), + "id": Property(identifier=True), + "address": Property(), + "netmask": Property(), + "note": Property(), } class Profile(Base): api_endpoint = "/profile" - id_attribute = 'username' + id_attribute = "username" properties = { - 'username': Property(identifier=True), - 'uid': Property(), - 'email': Property(mutable=True), - 'timezone': Property(mutable=True), - 'email_notifications': Property(mutable=True), - 'referrals': Property(), - 'ip_whitelist_enabled': Property(mutable=True), - 'lish_auth_method': Property(mutable=True), - 'authorized_keys': Property(mutable=True), - 'two_factor_auth': Property(), - 'restricted': Property(), + "username": Property(identifier=True), + "uid": Property(), + "email": Property(mutable=True), + "timezone": Property(mutable=True), + "email_notifications": Property(mutable=True), + "referrals": Property(), + "ip_whitelist_enabled": Property(mutable=True), + "lish_auth_method": Property(mutable=True), + "authorized_keys": Property(mutable=True), + "two_factor_auth": Property(), + "restricted": Property(), } def enable_tfa(self): @@ -63,17 +63,17 @@ def enable_tfa(self): Enables TFA for the token's user. This requies a follow-up request to confirm TFA. Returns the TFA secret that needs to be confirmed. """ - result = self._client.post('/profile/tfa-enable') + result = self._client.post("/profile/tfa-enable") - return result['secret'] + return result["secret"] def confirm_tfa(self, code): """ Confirms TFA for an account. Needs a TFA code generated by enable_tfa """ - self._client.post('/profile/tfa-enable-confirm', data={ - "tfa_code": code - }) + self._client.post( + "/profile/tfa-enable-confirm", data={"tfa_code": code} + ) return True @@ -81,7 +81,7 @@ def disable_tfa(self): """ Turns off TFA for this user's account. """ - self._client.post('/profile/tfa-disable') + self._client.post("/profile/tfa-disable") return True @@ -90,8 +90,13 @@ def grants(self): """ Returns grants for the current user """ - from linode_api4.objects.account import UserGrants # pylint: disable-all - resp = self._client.get('/profile/grants') # use special endpoint for restricted users + from linode_api4.objects.account import ( # pylint: disable-all + UserGrants, + ) + + resp = self._client.get( + "/profile/grants" + ) # use special endpoint for restricted users grants = None if resp is not None: @@ -111,24 +116,29 @@ def add_whitelist_entry(self, address, netmask, note=None): """ Adds a new entry to this user's IP whitelist, if enabled """ - result = self._client.post("{}/whitelist".format(Profile.api_endpoint), - data={ - "address": address, - "netmask": netmask, - "note": note, - }) + result = self._client.post( + "{}/whitelist".format(Profile.api_endpoint), + data={ + "address": address, + "netmask": netmask, + "note": note, + }, + ) - if not 'id' in result: - raise UnexpectedResponseError("Unexpected response creating whitelist entry!") + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating whitelist entry!" + ) - return WhitelistEntry(result['id'], self._client, json=result) + return WhitelistEntry(result["id"], self._client, json=result) class SSHKey(Base): """ An SSH Public Key uploaded to your profile for use in Linode Instance deployments. """ - api_endpoint = '/profile/sshkeys/{id}' + + api_endpoint = "/profile/sshkeys/{id}" properties = { "id": Property(identifier=True), diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 4b3b87e57..300ed7e62 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -4,9 +4,9 @@ class Region(Base): api_endpoint = "/regions/{id}" properties = { - 'id': Property(identifier=True), - 'country': Property(filterable=True), - 'capabilities': Property(), - 'status': Property(), - 'resolvers': Property(), + "id": Property(identifier=True), + "country": Property(filterable=True), + "capabilities": Property(), + "status": Property(), + "resolvers": Property(), } diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index 1270c15fa..965346b8f 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -1,77 +1,87 @@ import requests from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.objects import (Base, DerivedBase, Domain, Instance, Property, - Volume) +from linode_api4.objects import ( + Base, + DerivedBase, + Domain, + Instance, + Property, + Volume, +) from linode_api4.objects.nodebalancer import NodeBalancer class TicketReply(DerivedBase): - api_endpoint = '/support/tickets/{ticket_id}/replies' - derived_url_path = 'replies' - parent_id_name='ticket_id' + api_endpoint = "/support/tickets/{ticket_id}/replies" + derived_url_path = "replies" + parent_id_name = "ticket_id" properties = { - 'id': Property(identifier=True), - 'ticket_id': Property(identifier=True), - 'description': Property(), - 'created': Property(is_datetime=True), - 'created_by': Property(), - 'from_linode': Property(), + "id": Property(identifier=True), + "ticket_id": Property(identifier=True), + "description": Property(), + "created": Property(is_datetime=True), + "created_by": Property(), + "from_linode": Property(), } class SupportTicket(Base): - api_endpoint = '/support/tickets/{id}' + api_endpoint = "/support/tickets/{id}" properties = { - 'id': Property(identifier=True), - 'summary': Property(), - 'description': Property(), - 'status': Property(filterable=True), - 'entity': Property(), - 'opened': Property(is_datetime=True), - 'closed': Property(is_datetime=True), - 'updated': Property(is_datetime=True), - 'updated_by': Property(), - 'replies': Property(derived_class=TicketReply), + "id": Property(identifier=True), + "summary": Property(), + "description": Property(), + "status": Property(filterable=True), + "entity": Property(), + "opened": Property(is_datetime=True), + "closed": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "replies": Property(derived_class=TicketReply), } @property def linode(self): - if self.entity and self.entity.type == 'linode': + if self.entity and self.entity.type == "linode": return Instance(self._client, self.entity.id) return None @property def domain(self): - if self.entity and self.entity.type == 'domain': + if self.entity and self.entity.type == "domain": return Domain(self._client, self.entity.id) return None @property def nodebalancer(self): - if self.entity and self.entity.type == 'nodebalancer': + if self.entity and self.entity.type == "nodebalancer": return NodeBalancer(self._client, self.entity.id) return None @property def volume(self): - if self.entity and self.entity.type == 'volume': + if self.entity and self.entity.type == "volume": return Volume(self._client, self.entity.id) return None def post_reply(self, description): - """ - """ - result = self._client.post("{}/replies".format(SupportTicket.api_endpoint), model=self, data={ - "description": description, - }) - - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when creating ticket reply!', - json=result) - - r = TicketReply(self._client, result['id'], self.id, result) + """ """ + result = self._client.post( + "{}/replies".format(SupportTicket.api_endpoint), + model=self, + data={ + "description": description, + }, + ) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating ticket reply!", json=result + ) + + r = TicketReply(self._client, result["id"], self.id, result) return r def upload_attachment(self, attachment): @@ -80,22 +90,27 @@ def upload_attachment(self, attachment): content = f.read() if not content: - raise ValueError('Nothing to upload!') + raise ValueError("Nothing to upload!") headers = { "Authorization": "token {}".format(self._client.token), "Content-type": "multipart/form-data", } - result = requests.post('{}{}/attachments'.format(self._client.base_url, - SupportTicket.api_endpoint.format(id=self.id)), - headers=headers, files=content) + result = requests.post( + "{}{}/attachments".format( + self._client.base_url, + SupportTicket.api_endpoint.format(id=self.id), + ), + headers=headers, + files=content, + ) if not result.status_code == 200: errors = [] j = result.json() - if 'errors' in j: - errors = [ e['reason'] for e in j['errors'] ] - raise ApiError('{}: {}'.format(result.status_code, errors), json=j) + if "errors" in j: + errors = [e["reason"] for e in j["errors"]] + raise ApiError("{}: {}".format(result.status_code, errors), json=j) return True diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 773a99af8..2ae75223f 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -1,30 +1,29 @@ -import string -import sys -from datetime import datetime -from enum import Enum -from os import urandom -from random import randint - from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects import ( + Base, + DerivedBase, + Domain, + Instance, + NodeBalancer, + Property, + Volume, +) from linode_api4.paginated_list import PaginatedList -from linode_api4.objects import (Base, DerivedBase, Property, Instance, Volume, - NodeBalancer, Domain) - CLASS_MAP = { - 'linode': Instance, - 'domain': Domain, - 'nodebalancer': NodeBalancer, - 'volume': Volume, + "linode": Instance, + "domain": Domain, + "nodebalancer": NodeBalancer, + "volume": Volume, } class Tag(Base): - api_endpoint = '/tags/{label}' - id_attribute = 'label' + api_endpoint = "/tags/{label}" + id_attribute = "label" properties = { - 'label': Property(identifier=True), + "label": Property(identifier=True), } def _get_raw_objects(self): @@ -33,12 +32,12 @@ def _get_raw_objects(self): This has the side effect of creating the ``_raw_objects`` attribute of this object. """ - if not hasattr(self, '_raw_objects'): + if not hasattr(self, "_raw_objects"): result = self._client.get(type(self).api_endpoint, model=self) # I want to cache this to avoid making duplicate requests, but I don't # want it in the __init__ - self._raw_objects = result # pylint: disable=attribute-defined-outside-init + self._raw_objects = result return self._raw_objects @@ -62,8 +61,12 @@ def objects(self): """ data = self._get_raw_objects() - return PaginatedList.make_paginated_list(data, self._client, TaggedObjectProxy, - page_url=type(self).api_endpoint.format(**vars(self))) + return PaginatedList.make_paginated_list( + data, + self._client, + TaggedObjectProxy, + page_url=type(self).api_endpoint.format(**vars(self)), + ) class TaggedObjectProxy: @@ -77,11 +80,14 @@ class TaggedObjectProxy: enveloped objects returned from the tagged objects collection, and should only be used in that context. """ - id_attribute = 'type' # the envelope containing tagged objects has a `type` field - # that defined what type of object is in the envelope. We'll - # use that as the ID for the proxy class so ``make_instance`` - # below can easily tell what type it should actually be - # making and returning. + + id_attribute = ( + "type" # the envelope containing tagged objects has a `type` field + ) + # that defined what type of object is in the envelope. We'll + # use that as the ID for the proxy class so ``make_instance`` + # below can easily tell what type it should actually be + # making and returning. @classmethod def make_instance(cls, id, client, parent_id=None, json=None): @@ -97,15 +103,19 @@ def make_instance(cls, id, client, parent_id=None, json=None): :returns: A new instance of this type, populated with json """ - make_cls = CLASS_MAP.get(id) # in this case, ID is coming in as the type + make_cls = CLASS_MAP.get( + id + ) # in this case, ID is coming in as the type if make_cls is None: # we don't recognize this entity type - do nothing? return None # discard the envelope - real_json = json['data'] - real_id = real_json['id'] + real_json = json["data"] + real_id = real_json["id"] # make the real object type - return Base.make(real_id, client, make_cls, parent_id=None, json=real_json) + return Base.make( + real_id, client, make_cls, parent_id=None, json=real_json + ) diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index aef36893c..5861613f0 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -3,35 +3,46 @@ class Volume(Base): - api_endpoint = '/volumes/{id}' + api_endpoint = "/volumes/{id}" properties = { - 'id': Property(identifier=True), - 'created': Property(is_datetime=True), - 'updated': Property(is_datetime=True), - 'linode_id': Property(id_relationship=Instance), - 'label': Property(mutable=True, filterable=True), - 'size': Property(filterable=True), - 'status': Property(filterable=True), - 'region': Property(slug_relationship=Region), - 'tags': Property(mutable=True), - 'filesystem_path': Property(), - 'hardware_type': Property(), - 'linode_label': Property(), + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "linode_id": Property(id_relationship=Instance), + "label": Property(mutable=True, filterable=True), + "size": Property(filterable=True), + "status": Property(filterable=True), + "region": Property(slug_relationship=Region), + "tags": Property(mutable=True), + "filesystem_path": Property(), + "hardware_type": Property(), + "linode_label": Property(), } def attach(self, to_linode, config=None): """ Attaches this Volume to the given Linode """ - result = self._client.post('{}/attach'.format(Volume.api_endpoint), model=self, - data={ - "linode_id": to_linode.id if issubclass(type(to_linode), Base) else to_linode, - "config": None if not config else config.id if issubclass(type(config), Base) else config, - }) - - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response when attaching volume!', json=result) + result = self._client.post( + "{}/attach".format(Volume.api_endpoint), + model=self, + data={ + "linode_id": to_linode.id + if issubclass(type(to_linode), Base) + else to_linode, + "config": None + if not config + else config.id + if issubclass(type(config), Base) + else config, + }, + ) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when attaching volume!", json=result + ) self._populate(result) return True @@ -40,7 +51,7 @@ def detach(self): """ Detaches this Volume if it is attached """ - self._client.post('{}/detach'.format(Volume.api_endpoint), model=self) + self._client.post("{}/detach".format(Volume.api_endpoint), model=self) return True @@ -48,8 +59,11 @@ def resize(self, size): """ Resizes this Volume """ - result = self._client.post('{}/resize'.format(Volume.api_endpoint), model=self, - data={ "size": size }) + result = self._client.post( + "{}/resize".format(Volume.api_endpoint), + model=self, + data={"size": size}, + ) self._populate(result) @@ -63,10 +77,13 @@ def clone(self, label): :returns: The new volume object. """ - result = self._client.post('{}/clone'.format(Volume.api_endpoint), - model=self, data={'label': label}) + result = self._client.post( + "{}/clone".format(Volume.api_endpoint), + model=self, + data={"label": label}, + ) - if not 'id' in result: - raise UnexpectedResponseError('Unexpected response cloning volume!') + if not "id" in result: + raise UnexpectedResponseError("Unexpected response cloning volume!") - return Volume(self._client, result['id'], result) + return Volume(self._client, result["id"], result) diff --git a/linode_api4/paginated_list.py b/linode_api4/paginated_list.py index c6b51b629..1db5bfc5d 100644 --- a/linode_api4/paginated_list.py +++ b/linode_api4/paginated_list.py @@ -29,19 +29,30 @@ class PaginatedList(object): This will _not_ emit another API request. """ - def __init__(self, client, page_endpoint, page=[], max_pages=1, - total_items=None, parent_id=None, filters=None): + + def __init__( + self, + client, + page_endpoint, + page=[], + max_pages=1, + total_items=None, + parent_id=None, + filters=None, + ): self.client = client self.page_endpoint = page_endpoint self.query_filters = filters self.page_size = len(page) self.max_pages = max_pages - self.lists = [ None for _ in range(0, self.max_pages) ] + self.lists = [None for _ in range(0, self.max_pages)] if self.lists: self.lists[0] = page - self.list_cls = type(page[0]) if page else None # TODO if this is None that's bad + self.list_cls = ( + type(page[0]) if page else None + ) # TODO if this is None that's bad self.objects_parent_id = parent_id - self.cur = 0 # for being a generator + self.cur = 0 # for being a generator self.total_items = total_items if not total_items: @@ -85,14 +96,24 @@ def __repr__(self): return "PaginatedList ({} items)".format(self.total_items) def _load_page(self, page_number): - j = self.client.get("/{}?page={}&page_size={}".format(self.page_endpoint, page_number+1, self.page_size), - filters=self.query_filters) - - if j['pages'] != self.max_pages or j['results'] != len(self): - raise RuntimeError('List {} has changed since creation!'.format(self)) - - l = PaginatedList.make_list(j["data"], self.client, self.list_cls, - parent_id=self.objects_parent_id) + j = self.client.get( + "/{}?page={}&page_size={}".format( + self.page_endpoint, page_number + 1, self.page_size + ), + filters=self.query_filters, + ) + + if j["pages"] != self.max_pages or j["results"] != len(self): + raise RuntimeError( + "List {} has changed since creation!".format(self) + ) + + l = PaginatedList.make_list( + j["data"], + self.client, + self.list_cls, + parent_id=self.objects_parent_id, + ) self.lists[page_number] = l def __getitem__(self, index): @@ -104,12 +125,12 @@ def __getitem__(self, index): if index < 0: index = len(self) + index if index < 0: - raise IndexError('list index out of range') + raise IndexError("list index out of range") if index >= self.page_size * self.max_pages: - raise IndexError('list index out of range') + raise IndexError("list index out of range") normalized_index = index % self.page_size - target_page = math.ceil((index+1.0)/self.page_size)-1 + target_page = math.ceil((index + 1.0) / self.page_size) - 1 target_page = int(target_page) if not self.lists[target_page]: @@ -127,7 +148,9 @@ def _get_slice(self, s): # we do not support steps outside of 1 yet if s.step is not None and s.step != 1: - raise NotImplementedError('Only step sizes of 1 are currently supported.') + raise NotImplementedError( + "Only step sizes of 1 are currently supported." + ) # if i or j are negative, normalize them if i < 0: @@ -138,7 +161,7 @@ def _get_slice(self, s): # if i or j are still negative, that's an IndexError if i < 0 or j < 0: - raise IndexError('list index out of range') + raise IndexError("list index out of range") # if we're going nowhere or backward, return nothing if j <= i: @@ -152,15 +175,17 @@ def _get_slice(self, s): return result def __setitem__(self, index, value): - raise AttributeError('Assigning to indicies in paginated lists is not supported') + raise AttributeError( + "Assigning to indicies in paginated lists is not supported" + ) def __delitem__(self, index): - raise AttributeError('Deleting from paginated lists is not supported') + raise AttributeError("Deleting from paginated lists is not supported") def __next__(self): if self.cur < len(self): self.cur += 1 - return self[self.cur-1] + return self[self.cur - 1] else: raise StopIteration() @@ -181,10 +206,13 @@ def make_list(json_arr, client, cls, parent_id=None): for obj in json_arr: id_val = None - if 'id' in obj: - id_val = obj['id'] - elif hasattr(cls, 'id_attribute') and getattr(cls, 'id_attribute') in obj: - id_val = obj[getattr(cls, 'id_attribute')] + if "id" in obj: + id_val = obj["id"] + elif ( + hasattr(cls, "id_attribute") + and getattr(cls, "id_attribute") in obj + ): + id_val = obj[getattr(cls, "id_attribute")] else: continue o = cls.make_instance(id_val, client, parent_id=parent_id, json=obj) @@ -193,8 +221,9 @@ def make_list(json_arr, client, cls, parent_id=None): return result @staticmethod - def make_paginated_list(json, client, cls, parent_id=None, page_url=None, - filters=None): + def make_paginated_list( + json, client, cls, parent_id=None, page_url=None, filters=None + ): """ Returns a PaginatedList populated with the first page of data provided, and the ability to load additional pages. This should not be called @@ -212,7 +241,16 @@ def make_paginated_list(json, client, cls, parent_id=None, page_url=None, :returns: An instance of PaginatedList that will represent the entire collection whose first page is json """ - l = PaginatedList.make_list(json["data"], client, cls, parent_id=parent_id) - p = PaginatedList(client, page_url, page=l, max_pages=json['pages'], - total_items=json['results'], parent_id=parent_id, filters=filters) + l = PaginatedList.make_list( + json["data"], client, cls, parent_id=parent_id + ) + p = PaginatedList( + client, + page_url, + page=l, + max_pages=json["pages"], + total_items=json["results"], + parent_id=parent_id, + filters=filters, + ) return p diff --git a/linode_api4/util.py b/linode_api4/util.py index 8dac5d35a..3a96fbc05 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -1,7 +1,7 @@ """ Contains various utility functions. """ -from typing import Dict, Any +from typing import Any, Dict def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]: @@ -14,7 +14,11 @@ def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]: def recursive_helper(value: Any) -> Any: if isinstance(value, dict): - return {k: recursive_helper(v) for k, v in value.items() if v is not None} + return { + k: recursive_helper(v) + for k, v in value.items() + if v is not None + } if isinstance(value, list): return [recursive_helper(v) for v in value] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..d04b49d84 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.isort] +profile = "black" +line_length = 80 + +[tool.black] +line-length = 80 +target-version = ["py37", "py38", "py39", "py310", "py311"] + +[tool.autoflake] +expand-star-imports = false +ignore-init-module-imports = true +ignore-pass-after-docstring = true +in-place = true +recursive = true +remove-all-unused-imports = false +remove-duplicate-keys = false diff --git a/requirements-dev.txt b/requirements-dev.txt index 0e3856ab2..a731835b8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,7 @@ +black>=23.1.0 +isort>=5.12.0 +autoflake>=2.0.1 +pylint mock>=5.0.0 tox>=4.4.0 Sphinx>=6.0.0 diff --git a/test/base.py b/test/base.py index 6e0b70984..f1e65d8ef 100644 --- a/test/base.py +++ b/test/base.py @@ -9,6 +9,7 @@ FIXTURES = TestFixtures() + class MockResponse: def __init__(self, status_code, json, headers={}): self.status_code = status_code @@ -30,7 +31,7 @@ def load_json(url): """ formatted_url = url - while formatted_url.startswith('/'): + while formatted_url.startswith("/"): formatted_url = formatted_url[1:] return FIXTURES.get_fixture(formatted_url) @@ -50,6 +51,7 @@ class MethodMock: This class is used to mock methods on requests and store the parameters and headers it was called with. """ + def __init__(self, method, return_dct): """ Creates and initiates a new MethodMock with the given details @@ -64,16 +66,18 @@ def __init__(self, method, return_dct): elif isinstance(return_dct, str): self.return_dct = load_json(return_dct) else: - raise TypeError('return_dct must be a dict or a URL from which the ' - 'JSON could be loaded') + raise TypeError( + "return_dct must be a dict or a URL from which the " + "JSON could be loaded" + ) def __enter__(self): """ Begins the method mocking """ self.patch = patch( - 'linode_api4.linode_client.requests.Session.'+self.method, - return_value=MockResponse(200, self.return_dct) + "linode_api4.linode_client.requests.Session." + self.method, + return_value=MockResponse(200, self.return_dct), ) self.mock = self.patch.start() return self @@ -96,7 +100,7 @@ def call_data_raw(self): """ A shortcut to access the raw call data, not parsed as JSON """ - return self.mock.call_args[1]['data'] + return self.mock.call_args[1]["data"] @property def call_url(self): @@ -113,16 +117,16 @@ def call_data(self): A shortcut to getting the data param this was called with. Removes all keys whose values are None """ - data = json.loads(self.mock.call_args[1]['data']) + data = json.loads(self.mock.call_args[1]["data"]) - return { k: v for k, v in data.items() if v is not None } + return {k: v for k, v in data.items() if v is not None} @property def call_headers(self): """ A shortcut to getting the headers param this was called with """ - return self.mock.call_args[1]['headers'] + return self.mock.call_args[1]["headers"] @property def called(self): @@ -131,18 +135,20 @@ def called(self): """ return self.mock.called + class ClientBaseCase(TestCase): def setUp(self): - self.client = LinodeClient('testing', base_url='/') + self.client = LinodeClient("testing", base_url="/") - self.get_patch = patch('linode_api4.linode_client.requests.Session.get', - side_effect=mock_get) + self.get_patch = patch( + "linode_api4.linode_client.requests.Session.get", + side_effect=mock_get, + ) self.get_patch.start() def tearDown(self): self.get_patch.stop() - def mock_get(self, return_dct): """ Returns a MethodMock mocking a GET. This should be used in a with @@ -153,7 +159,7 @@ def mock_get(self, return_dct): :returns: A MethodMock object who will capture the parameters of the mocked requests """ - return MethodMock('get', return_dct) + return MethodMock("get", return_dct) def mock_post(self, return_dct): """ @@ -165,7 +171,7 @@ def mock_post(self, return_dct): :returns: A MethodMock object who will capture the parameters of the mocked requests """ - return MethodMock('post', return_dct) + return MethodMock("post", return_dct) def mock_put(self, return_dct): """ @@ -177,7 +183,7 @@ def mock_put(self, return_dct): :returns: A MethodMock object who will capture the parameters of the mocked requests """ - return MethodMock('put', return_dct) + return MethodMock("put", return_dct) def mock_delete(self): """ @@ -189,4 +195,4 @@ def mock_delete(self): :returns: A MethodMock object who will capture the parameters of the mocked requests """ - return MethodMock('delete', {}) + return MethodMock("delete", {}) diff --git a/test/fixtures.py b/test/fixtures.py index cd1e63b80..94f3cee51 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -2,7 +2,8 @@ import os import sys -FIXTURES_DIR = sys.path[0] + '/test/fixtures' +FIXTURES_DIR = sys.path[0] + "/test/fixtures" + class TestFixtures: def __init__(self): @@ -16,7 +17,7 @@ def get_fixture(self, url): Returns the test fixture data loaded at the given URL """ return self.fixtures[url] - + def _load_fixtures(self): """ Handles loading JSON files and parsing them into responses. Also splits @@ -25,20 +26,20 @@ def _load_fixtures(self): self.fixtures = {} for json_file in os.listdir(FIXTURES_DIR): - if not json_file.endswith('.json'): + if not json_file.endswith(".json"): continue - with open(FIXTURES_DIR + '/' + json_file) as f: + with open(FIXTURES_DIR + "/" + json_file) as f: raw = f.read() data = json.loads(raw) - fixture_url = json_file.replace('_', '/')[:-5] - + fixture_url = json_file.replace("_", "/")[:-5] + self.fixtures[fixture_url] = data - if 'results' in data: + if "results" in data: # this is a paginated response - for obj in data['data']: - if 'id' in obj: # tags don't have ids - self.fixtures[fixture_url + '/' + str(obj['id'])] = obj + for obj in data["data"]: + if "id" in obj: # tags don't have ids + self.fixtures[fixture_url + "/" + str(obj["id"])] = obj diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 2c3003e33..d1e444c49 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -1,21 +1,21 @@ from datetime import datetime +from test.base import ClientBaseCase from unittest import TestCase from unittest.mock import MagicMock -from test.base import ClientBaseCase - -from linode_api4 import LongviewSubscription, LinodeClient, ApiError +from linode_api4 import ApiError, LinodeClient, LongviewSubscription class LinodeClientGeneralTest(ClientBaseCase): """ Tests methods of the LinodeClient class that do not live inside of a group. """ + def test_get_no_empty_body(self): """ Tests that a valid JSON body is passed for a GET call """ - with self.mock_get('linode/instances') as m: + with self.mock_get("linode/instances") as m: self.client.regions() self.assertEqual(m.call_data_raw, None) @@ -24,20 +24,23 @@ def test_get_account(self): a = self.client.account() self.assertEqual(a._populated, True) - self.assertEqual(a.first_name, 'Test') - self.assertEqual(a.last_name, 'Guy') - self.assertEqual(a.email, 'support@linode.com') - self.assertEqual(a.phone, '123-456-7890') - self.assertEqual(a.company, 'Linode') - self.assertEqual(a.address_1, '3rd & Arch St') - self.assertEqual(a.address_2, '') - self.assertEqual(a.city, 'Philadelphia') - self.assertEqual(a.state, 'PA') - self.assertEqual(a.country, 'US') - self.assertEqual(a.zip, '19106') - self.assertEqual(a.tax_id, '') + self.assertEqual(a.first_name, "Test") + self.assertEqual(a.last_name, "Guy") + self.assertEqual(a.email, "support@linode.com") + self.assertEqual(a.phone, "123-456-7890") + self.assertEqual(a.company, "Linode") + self.assertEqual(a.address_1, "3rd & Arch St") + self.assertEqual(a.address_2, "") + self.assertEqual(a.city, "Philadelphia") + self.assertEqual(a.state, "PA") + self.assertEqual(a.country, "US") + self.assertEqual(a.zip, "19106") + self.assertEqual(a.tax_id, "") self.assertEqual(a.balance, 0) - self.assertEqual(a.capabilities, ["Linodes","NodeBalancers","Block Storage","Object Storage"]) + self.assertEqual( + a.capabilities, + ["Linodes", "NodeBalancers", "Block Storage", "Object Storage"], + ) def test_get_regions(self): r = self.client.regions() @@ -47,10 +50,21 @@ def test_get_regions(self): self.assertTrue(region._populated) self.assertIsNotNone(region.id) self.assertIsNotNone(region.country) - if region.id in ('us-east', 'eu-central', 'ap-south'): - self.assertEqual(region.capabilities, ["Linodes","NodeBalancers","Block Storage","Object Storage"]) + if region.id in ("us-east", "eu-central", "ap-south"): + self.assertEqual( + region.capabilities, + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage", + ], + ) else: - self.assertEqual(region.capabilities, ["Linodes","NodeBalancers","Block Storage"]) + self.assertEqual( + region.capabilities, + ["Linodes", "NodeBalancers", "Block Storage"], + ) self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) self.assertIsNotNone(region.resolvers.ipv4) @@ -73,54 +87,72 @@ def test_get_domains(self): self.assertEqual(len(r), 1) domain = r.first() - self.assertEqual(domain.domain, 'example.org') - self.assertEqual(domain.type, 'master') + self.assertEqual(domain.domain, "example.org") + self.assertEqual(domain.type, "master") self.assertEqual(domain.id, 12345) self.assertEqual(domain.axfr_ips, []) self.assertEqual(domain.retry_sec, 0) self.assertEqual(domain.ttl_sec, 300) - self.assertEqual(domain.status, 'active') - self.assertEqual(domain.master_ips, [],) - self.assertEqual(domain.description, "",) - self.assertEqual(domain.group, "",) - self.assertEqual(domain.expire_sec, 0,) - self.assertEqual(domain.soa_email, "test@example.org",) + self.assertEqual(domain.status, "active") + self.assertEqual( + domain.master_ips, + [], + ) + self.assertEqual( + domain.description, + "", + ) + self.assertEqual( + domain.group, + "", + ) + self.assertEqual( + domain.expire_sec, + 0, + ) + self.assertEqual( + domain.soa_email, + "test@example.org", + ) self.assertEqual(domain.refresh_sec, 0) def test_image_create(self): """ Tests that an Image can be created successfully """ - with self.mock_post('images/private/123') as m: - i = self.client.image_create(654, 'Test-Image', 'This is a test') + with self.mock_post("images/private/123") as m: + i = self.client.image_create(654, "Test-Image", "This is a test") self.assertIsNotNone(i) - self.assertEqual(i.id, 'private/123') + self.assertEqual(i.id, "private/123") - self.assertEqual(m.call_url, '/images') + self.assertEqual(m.call_url, "/images") - self.assertEqual(m.call_data, { - "disk_id": 654, - "label": "Test-Image", - "description": "This is a test", - }) + self.assertEqual( + m.call_data, + { + "disk_id": 654, + "label": "Test-Image", + "description": "This is a test", + }, + ) def test_get_volumes(self): v = self.client.volumes() self.assertEqual(len(v), 3) - self.assertEqual(v[0].label, 'block1') - self.assertEqual(v[0].region.id, 'us-east-1a') - self.assertEqual(v[1].label, 'block2') + self.assertEqual(v[0].label, "block1") + self.assertEqual(v[0].region.id, "us-east-1a") + self.assertEqual(v[1].label, "block2") self.assertEqual(v[1].size, 100) self.assertEqual(v[2].size, 200) - self.assertEqual(v[2].label, 'block3') - self.assertEqual(v[0].filesystem_path, 'this/is/a/file/path') - self.assertEqual(v[0].hardware_type, 'hdd') - self.assertEqual(v[1].filesystem_path, 'this/is/a/file/path') + self.assertEqual(v[2].label, "block3") + self.assertEqual(v[0].filesystem_path, "this/is/a/file/path") + self.assertEqual(v[0].hardware_type, "hdd") + self.assertEqual(v[1].filesystem_path, "this/is/a/file/path") self.assertEqual(v[1].linode_label, None) - self.assertEqual(v[2].filesystem_path, 'this/is/a/file/path') - self.assertEqual(v[2].hardware_type, 'nvme') + self.assertEqual(v[2].filesystem_path, "this/is/a/file/path") + self.assertEqual(v[2].hardware_type, "nvme") assert v[0].tags == ["something"] assert v[1].tags == [] @@ -133,24 +165,27 @@ def test_get_tags(self): t = self.client.tags() self.assertEqual(len(t), 2) - self.assertEqual(t[0].label, 'nothing') - self.assertEqual(t[1].label, 'something') + self.assertEqual(t[0].label, "nothing") + self.assertEqual(t[1].label, "something") def test_tag_create(self): """ Tests that creating a tag works as expected """ # tags don't work like a normal RESTful collection, so we have to do this - with self.mock_post({'label':'nothing'}) as m: - t = self.client.tag_create('nothing') + with self.mock_post({"label": "nothing"}) as m: + t = self.client.tag_create("nothing") self.assertIsNotNone(t) - self.assertEqual(t.label, 'nothing') - - self.assertEqual(m.call_url, '/tags') - self.assertEqual(m.call_data, { - 'label': 'nothing', - }) + self.assertEqual(t.label, "nothing") + + self.assertEqual(m.call_url, "/tags") + self.assertEqual( + m.call_data, + { + "label": "nothing", + }, + ) def test_tag_create_with_ids(self): """ @@ -162,24 +197,29 @@ def test_tag_create_with_ids(self): volume1, volume2 = self.client.volumes()[:2] # tags don't work like a normal RESTful collection, so we have to do this - with self.mock_post({'label':'pytest'}) as m: - t = self.client.tag_create('pytest', - instances=[instance1.id, instance2], - nodebalancers=[nodebalancer1.id, nodebalancer2], - domains=[domain1.id], - volumes=[volume1.id, volume2]) + with self.mock_post({"label": "pytest"}) as m: + t = self.client.tag_create( + "pytest", + instances=[instance1.id, instance2], + nodebalancers=[nodebalancer1.id, nodebalancer2], + domains=[domain1.id], + volumes=[volume1.id, volume2], + ) self.assertIsNotNone(t) - self.assertEqual(t.label, 'pytest') - - self.assertEqual(m.call_url, '/tags') - self.assertEqual(m.call_data, { - 'label': 'pytest', - 'linodes': [instance1.id, instance2.id], - 'domains': [domain1.id], - 'nodebalancers': [nodebalancer1.id, nodebalancer2.id], - 'volumes': [volume1.id, volume2.id], - }) + self.assertEqual(t.label, "pytest") + + self.assertEqual(m.call_url, "/tags") + self.assertEqual( + m.call_data, + { + "label": "pytest", + "linodes": [instance1.id, instance2.id], + "domains": [domain1.id], + "nodebalancers": [nodebalancer1.id, nodebalancer2.id], + "volumes": [volume1.id, volume2.id], + }, + ) def test_tag_create_with_entities(self): """ @@ -191,27 +231,33 @@ def test_tag_create_with_entities(self): volume = self.client.volumes().first() # tags don't work like a normal RESTful collection, so we have to do this - with self.mock_post({'label':'pytest'}) as m: - t = self.client.tag_create('pytest', - entities=[instance1, domain, nodebalancer, volume, instance2]) + with self.mock_post({"label": "pytest"}) as m: + t = self.client.tag_create( + "pytest", + entities=[instance1, domain, nodebalancer, volume, instance2], + ) self.assertIsNotNone(t) - self.assertEqual(t.label, 'pytest') - - self.assertEqual(m.call_url, '/tags') - self.assertEqual(m.call_data, { - 'label': 'pytest', - 'linodes': [instance1.id, instance2.id], - 'domains': [domain.id], - 'nodebalancers': [nodebalancer.id], - 'volumes': [volume.id], - }) + self.assertEqual(t.label, "pytest") + + self.assertEqual(m.call_url, "/tags") + self.assertEqual( + m.call_data, + { + "label": "pytest", + "linodes": [instance1.id, instance2.id], + "domains": [domain.id], + "nodebalancers": [nodebalancer.id], + "volumes": [volume.id], + }, + ) class AccountGroupTest(ClientBaseCase): """ Tests methods of the AccountGroup """ + def test_get_settings(self): """ Tests that account settings can be retrieved. @@ -222,7 +268,7 @@ def test_get_settings(self): self.assertEqual(s.network_helper, False) self.assertEqual(s.managed, False) self.assertEqual(type(s.longview_subscription), LongviewSubscription) - self.assertEqual(s.longview_subscription.id, 'longview-100') + self.assertEqual(s.longview_subscription.id, "longview-100") self.assertEqual(s.object_storage, "active") def test_get_invoices(self): @@ -236,7 +282,7 @@ def test_get_invoices(self): self.assertEqual(invoice.id, 123456) self.assertEqual(invoice.date, datetime(2015, 1, 1, 5, 1, 2)) - self.assertEqual(invoice.label, 'Invoice #123456') + self.assertEqual(invoice.label, "Invoice #123456") self.assertEqual(invoice.total, 9.51) def test_payments(self): @@ -257,47 +303,55 @@ class LinodeGroupTest(ClientBaseCase): """ Tests methods of the LinodeGroup """ + def test_instance_create(self): """ Tests that a Linode Instance can be created successfully """ - with self.mock_post('linode/instances/123') as m: - l = self.client.linode.instance_create('g5-standard-1', 'us-east-1a') + with self.mock_post("linode/instances/123") as m: + l = self.client.linode.instance_create( + "g5-standard-1", "us-east-1a" + ) self.assertIsNotNone(l) self.assertEqual(l.id, 123) - self.assertEqual(m.call_url, '/linode/instances') + self.assertEqual(m.call_url, "/linode/instances") - self.assertEqual(m.call_data, { - "region": "us-east-1a", - "type": "g5-standard-1" - }) + self.assertEqual( + m.call_data, {"region": "us-east-1a", "type": "g5-standard-1"} + ) def test_instance_create_with_image(self): """ Tests that a Linode Instance can be created with an image, and a password generated """ - with self.mock_post('linode/instances/123') as m: + with self.mock_post("linode/instances/123") as m: l, pw = self.client.linode.instance_create( - 'g5-standard-1', 'us-east-1a', image='linode/debian9') + "g5-standard-1", "us-east-1a", image="linode/debian9" + ) self.assertIsNotNone(l) self.assertEqual(l.id, 123) - self.assertEqual(m.call_url, '/linode/instances') + self.assertEqual(m.call_url, "/linode/instances") + + self.assertEqual( + m.call_data, + { + "region": "us-east-1a", + "type": "g5-standard-1", + "image": "linode/debian9", + "root_pass": pw, + }, + ) - self.assertEqual(m.call_data, { - "region": "us-east-1a", - "type": "g5-standard-1", - "image": "linode/debian9", - "root_pass": pw, - }) class LongviewGroupTest(ClientBaseCase): """ Tests methods of the LongviewGroup """ + def test_get_clients(self): """ Tests that a list of LongviewClients can be retrieved @@ -314,28 +368,28 @@ def test_client_create(self): """ Tests that creating a client calls the api correctly """ - with self.mock_post('longview/clients/5678') as m: + with self.mock_post("longview/clients/5678") as m: client = self.client.longview.client_create() self.assertIsNotNone(client) self.assertEqual(client.id, 5678) - self.assertEqual(client.label, 'longview5678') + self.assertEqual(client.label, "longview5678") - self.assertEqual(m.call_url, '/longview/clients') + self.assertEqual(m.call_url, "/longview/clients") self.assertEqual(m.call_data, {}) def test_client_create_with_label(self): """ Tests that creating a client with a label calls the api correctly """ - with self.mock_post('longview/clients/1234') as m: - client = self.client.longview.client_create(label='test_client_1') + with self.mock_post("longview/clients/1234") as m: + client = self.client.longview.client_create(label="test_client_1") self.assertIsNotNone(client) self.assertEqual(client.id, 1234) - self.assertEqual(client.label, 'test_client_1') + self.assertEqual(client.label, "test_client_1") - self.assertEqual(m.call_url, '/longview/clients') + self.assertEqual(m.call_url, "/longview/clients") self.assertEqual(m.call_data, {"label": "test_client_1"}) def test_get_subscriptions(self): @@ -362,6 +416,7 @@ class LKEGroupTest(ClientBaseCase): """ Tests methods of the LKEGroupTest """ + def test_kube_version(self): """ Tests that KubeVersions can be retrieved @@ -385,8 +440,9 @@ def test_cluster_create_with_api_objects(self): region, "example-cluster", node_pools, version ) self.assertEqual(m.call_data["region"], "ap-west") - self.assertEqual(m.call_data["node_pools"], - [{"type": "g5-nanode-1", "count": 3}]) + self.assertEqual( + m.call_data["node_pools"], [{"type": "g5-nanode-1", "count": 3}] + ) self.assertEqual(m.call_data["k8s_version"], "1.19") self.assertEqual(cluster.id, 18881) @@ -399,12 +455,16 @@ def test_cluster_create_with_string_repr(self): """ with self.mock_post("lke/clusters") as m: cluster = self.client.lke.cluster_create( - "ap-west", "example-cluster", - {"type": "g6-standard-1", "count": 3}, "1.19" + "ap-west", + "example-cluster", + {"type": "g6-standard-1", "count": 3}, + "1.19", ) self.assertEqual(m.call_data["region"], "ap-west") - self.assertEqual(m.call_data["node_pools"], - [{"type": "g6-standard-1", "count": 3}]) + self.assertEqual( + m.call_data["node_pools"], + [{"type": "g6-standard-1", "count": 3}], + ) self.assertEqual(m.call_data["k8s_version"], "1.19") self.assertEqual(cluster.id, 18881) @@ -416,6 +476,7 @@ class ProfileGroupTest(ClientBaseCase): """ Tests methods of the ProfileGroup """ + def test_get_sshkeys(self): """ Tests that a list of SSH Keys can be retrieved @@ -426,89 +487,99 @@ def test_get_sshkeys(self): key1, key2 = r - self.assertEqual(key1.label, 'Home Ubuntu PC') - self.assertEqual(key1.created, datetime(year=2018, month=9, day=14, hour=13, - minute=0, second=0)) + self.assertEqual(key1.label, "Home Ubuntu PC") + self.assertEqual( + key1.created, + datetime(year=2018, month=9, day=14, hour=13, minute=0, second=0), + ) self.assertEqual(key1.id, 22) self.assertEqual( - key1.ssh_key, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" - "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" - "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" - "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" - "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" - "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" - "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" - "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" - "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" - "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" - "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" - "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" - "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" - "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command") + key1.ssh_key, + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" + "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" + "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" + "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" + "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" + "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" + "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" + "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" + "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" + "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" + "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" + "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" + "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" + "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command", + ) def test_client_create(self): """ Tests that creating a client calls the api correctly """ - with self.mock_post('longview/clients/5678') as m: + with self.mock_post("longview/clients/5678") as m: client = self.client.longview.client_create() self.assertIsNotNone(client) self.assertEqual(client.id, 5678) - self.assertEqual(client.label, 'longview5678') + self.assertEqual(client.label, "longview5678") - self.assertEqual(m.call_url, '/longview/clients') + self.assertEqual(m.call_url, "/longview/clients") self.assertEqual(m.call_data, {}) def test_ssh_key_create(self): """ Tests that creating an ssh key works as expected """ - with self.mock_post('profile/sshkeys/72') as m: + with self.mock_post("profile/sshkeys/72") as m: key = self.client.profile.ssh_key_upload( - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" - "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" - "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" - "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" - "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" - "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" - "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" - "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" - "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" - "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" - "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" - "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" - "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" - "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command", - 'Work Laptop') + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" + "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" + "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" + "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" + "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" + "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" + "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" + "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" + "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" + "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" + "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" + "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" + "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" + "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command", + "Work Laptop", + ) self.assertIsNotNone(key) self.assertEqual(key.id, 72) - self.assertEqual(key.label, 'Work Laptop') - - self.assertEqual(m.call_url, '/profile/sshkeys') - self.assertEqual(m.call_data, { - "ssh_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" - "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" - "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" - "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" - "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" - "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" - "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" - "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" - "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" - "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" - "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" - "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" - "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" - "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command", - "label": "Work Laptop" - }) + self.assertEqual(key.label, "Work Laptop") + + self.assertEqual(m.call_url, "/profile/sshkeys") + self.assertEqual( + m.call_data, + { + "ssh_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" + "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" + "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" + "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" + "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" + "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" + "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" + "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" + "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" + "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" + "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" + "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" + "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" + "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w==dorthu@dorthu-command", + "label": "Work Laptop", + }, + ) + class ObjectStorageGroupTest(ClientBaseCase): """ Tests for the ObjectStorageGroup """ + def test_get_clusters(self): """ Tests that Object Storage Clusters can be retrieved @@ -518,10 +589,12 @@ def test_get_clusters(self): self.assertEqual(len(clusters), 1) cluster = clusters[0] - self.assertEqual(cluster.id, 'us-east-1') - self.assertEqual(cluster.region.id, 'us-east') - self.assertEqual(cluster.domain, 'us-east-1.linodeobjects.com') - self.assertEqual(cluster.static_site_domain, 'website-us-east-1.linodeobjects.com') + self.assertEqual(cluster.id, "us-east-1") + self.assertEqual(cluster.region.id, "us-east") + self.assertEqual(cluster.domain, "us-east-1.linodeobjects.com") + self.assertEqual( + cluster.static_site_domain, "website-us-east-1.linodeobjects.com" + ) def test_get_keys(self): """ @@ -534,33 +607,37 @@ def test_get_keys(self): key2 = keys[1] self.assertEqual(key1.id, 1) - self.assertEqual(key1.label, 'object-storage-key-1') - self.assertEqual(key1.access_key, 'testAccessKeyHere123') - self.assertEqual(key1.secret_key, '[REDACTED]') + self.assertEqual(key1.label, "object-storage-key-1") + self.assertEqual(key1.access_key, "testAccessKeyHere123") + self.assertEqual(key1.secret_key, "[REDACTED]") self.assertEqual(key2.id, 2) - self.assertEqual(key2.label, 'object-storage-key-2') - self.assertEqual(key2.access_key, 'testAccessKeyHere456') - self.assertEqual(key2.secret_key, '[REDACTED]') + self.assertEqual(key2.label, "object-storage-key-2") + self.assertEqual(key2.access_key, "testAccessKeyHere456") + self.assertEqual(key2.secret_key, "[REDACTED]") def test_keys_create(self): """ Tests that you can create Object Storage Keys """ - with self.mock_post('object-storage/keys/1') as m: - keys = self.client.object_storage.keys_create('object-storage-key-1') + with self.mock_post("object-storage/keys/1") as m: + keys = self.client.object_storage.keys_create( + "object-storage-key-1" + ) self.assertIsNotNone(keys) self.assertEqual(keys.id, 1) - self.assertEqual(keys.label, 'object-storage-key-1') + self.assertEqual(keys.label, "object-storage-key-1") + + self.assertEqual(m.call_url, "/object-storage/keys") + self.assertEqual(m.call_data, {"label": "object-storage-key-1"}) - self.assertEqual(m.call_url, '/object-storage/keys') - self.assertEqual(m.call_data, {"label":"object-storage-key-1"}) class NetworkingGroupTest(ClientBaseCase): """ Tests for the NetworkingGroup """ + def test_get_vlans(self): """ Tests that Object Storage Clusters can be retrieved @@ -568,36 +645,40 @@ def test_get_vlans(self): vlans = self.client.networking.vlans() self.assertEqual(len(vlans), 1) - self.assertEqual(vlans[0].label, 'vlan-test') - self.assertEqual(vlans[0].region.id, 'us-southeast') + self.assertEqual(vlans[0].label, "vlan-test") + self.assertEqual(vlans[0].region.id, "us-southeast") self.assertEqual(len(vlans[0].linodes), 2) self.assertEqual(vlans[0].linodes[0], 111) self.assertEqual(vlans[0].linodes[1], 222) def test_firewall_create(self): - with self.mock_post('networking/firewalls/123') as m: + with self.mock_post("networking/firewalls/123") as m: rules = { - 'outbound': [], - 'outbound_policy': 'DROP', - 'inbound': [], - 'inbound_policy': 'DROP' + "outbound": [], + "outbound_policy": "DROP", + "inbound": [], + "inbound_policy": "DROP", } - f = self.client.networking.firewall_create('test-firewall-1', rules, - status='enabled') + f = self.client.networking.firewall_create( + "test-firewall-1", rules, status="enabled" + ) self.assertIsNotNone(f) - self.assertEqual(m.call_url, '/networking/firewalls') - self.assertEqual(m.method, 'post') + self.assertEqual(m.call_url, "/networking/firewalls") + self.assertEqual(m.method, "post") self.assertEqual(f.id, 123) - self.assertEqual(m.call_data, { - 'label': 'test-firewall-1', - 'status': 'enabled', - 'rules': rules - }) + self.assertEqual( + m.call_data, + { + "label": "test-firewall-1", + "status": "enabled", + "rules": rules, + }, + ) def test_get_firewalls(self): """ @@ -621,8 +702,11 @@ class LinodeClientRateLimitRetryTest(TestCase): pertain to the 429 retry logic, and make sure you mock the requests calls yourself (or else they will make real requests and those won't work). """ + def setUp(self): - self.client = LinodeClient("testing", base_url="/", retry_rate_limit_interval=1) + self.client = LinodeClient( + "testing", base_url="/", retry_rate_limit_interval=1 + ) # sidestep the validation to do immediate retries so tests aren't slow self.client.retry_rate_limit_interval = 0.1 @@ -641,6 +725,7 @@ def test_retry_429s(self): Tests that 429 responses are automatically retried """ called = 0 + def test_method(*args, **kwargs): nonlocal called called += 1 @@ -648,7 +733,7 @@ def test_method(*args, **kwargs): return self._get_mock_response(429) return self._get_mock_response(200) - response = self.client._api_call('/test', method=test_method) + response = self.client._api_call("/test", method=test_method) # it retried once, got the empty object assert called == 2 @@ -659,13 +744,14 @@ def test_retry_max_attempts(self): Tests that a request will fail after 5 429 responses in a row """ called = 0 + def test_method(*args, **kwargs): nonlocal called called += 1 return self._get_mock_response(429) try: - response = self.client._api_call('/test', method=test_method) + response = self.client._api_call("/test", method=test_method) assert False, "Unexpectedly did not raise ApiError!" except ApiError as e: assert e.status == 429 @@ -679,13 +765,14 @@ def test_api_error_with_retry(self): enabled """ called = 0 + def test_method(*args, **kwargs): nonlocal called called += 1 return self._get_mock_response(400) try: - response = self.client._api_call('/test', method=test_method) + response = self.client._api_call("/test", method=test_method) assert False, "Unexpectedly did not raise ApiError!" except ApiError as e: assert e.status == 400 @@ -699,6 +786,7 @@ def test_api_error_on_retry(self): response after a 429 """ called = 0 + def test_method(*args, **kwargs): nonlocal called called += 1 @@ -707,7 +795,7 @@ def test_method(*args, **kwargs): return self._get_mock_response(400) try: - response = self.client._api_call('/test', method=test_method) + response = self.client._api_call("/test", method=test_method) assert False, "Unexpectedly did not raise ApiError!" except ApiError as e: assert e.status == 400 @@ -721,12 +809,13 @@ def test_works_first_time(self): try """ called = 0 + def test_method(*args, **kwargs): nonlocal called called += 1 return self._get_mock_response(200) - response = self.client._api_call('/test', method=test_method) + response = self.client._api_call("/test", method=test_method) # it tried 5 times assert called == 1 diff --git a/test/objects/account_test.py b/test/objects/account_test.py index f869eb764..03d07fbaa 100644 --- a/test/objects/account_test.py +++ b/test/objects/account_test.py @@ -1,5 +1,4 @@ from datetime import datetime - from test.base import ClientBaseCase from linode_api4.objects import Invoice @@ -9,11 +8,12 @@ class InvoiceTest(ClientBaseCase): """ Tests methods of the Invoice """ + def test_get_invoice(self): invoice = Invoice(self.client, 123456) self.assertEqual(invoice._populated, False) - self.assertEqual(invoice.label, 'Invoice #123456') + self.assertEqual(invoice.label, "Invoice #123456") self.assertEqual(invoice._populated, True) self.assertEqual(invoice.date, datetime(2015, 1, 1, 5, 1, 2)) @@ -34,5 +34,11 @@ def test_get_invoice_items(self): self.assertEqual(item.amount, 9.51) self.assertEqual(item.quantity, 317) self.assertEqual(item.unit_price, "0.03") - self.assertEqual(item.from_date, datetime(year=2014, month=12, day=19, hour=0, minute=27, second=2)) - self.assertEqual(item.to_date, datetime(year=2015, month=1, day=1, hour=4, minute=59, second=59)) + self.assertEqual( + item.from_date, + datetime(year=2014, month=12, day=19, hour=0, minute=27, second=2), + ) + self.assertEqual( + item.to_date, + datetime(year=2015, month=1, day=1, hour=4, minute=59, second=59), + ) diff --git a/test/objects/database_test.py b/test/objects/database_test.py index 8aed8e182..a70f3ae54 100644 --- a/test/objects/database_test.py +++ b/test/objects/database_test.py @@ -1,6 +1,6 @@ -from linode_api4 import PostgreSQLDatabase, MongoDBDatabase from test.base import ClientBaseCase +from linode_api4 import MongoDBDatabase, PostgreSQLDatabase from linode_api4.objects import MySQLDatabase @@ -16,8 +16,8 @@ def test_get_types(self): types = self.client.database.types() self.assertEqual(len(types), 1) - self.assertEqual(types[0].type_class, 'nanode') - self.assertEqual(types[0].id, 'g6-nanode-1') + self.assertEqual(types[0].type_class, "nanode") + self.assertEqual(types[0].id, "g6-nanode-1") self.assertEqual(types[0].engines.mongodb[0].price.monthly, 20) def test_get_engines(self): @@ -28,13 +28,13 @@ def test_get_engines(self): self.assertEqual(len(engines), 2) - self.assertEqual(engines[0].engine, 'mysql') - self.assertEqual(engines[0].id, 'mysql/8.0.26') - self.assertEqual(engines[0].version, '8.0.26') + self.assertEqual(engines[0].engine, "mysql") + self.assertEqual(engines[0].id, "mysql/8.0.26") + self.assertEqual(engines[0].version, "8.0.26") - self.assertEqual(engines[1].engine, 'postgresql') - self.assertEqual(engines[1].id, 'postgresql/10.14') - self.assertEqual(engines[1].version, '10.14') + self.assertEqual(engines[1].engine, "postgresql") + self.assertEqual(engines[1].id, "postgresql/10.14") + self.assertEqual(engines[1].version, "10.14") def test_get_databases(self): """ @@ -43,16 +43,22 @@ def test_get_databases(self): dbs = self.client.database.instances() self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") self.assertEqual(dbs[0].cluster_size, 3) self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, 'mysql') - self.assertEqual(dbs[0].hosts.primary, 'lin-123-456-mysql-mysql-primary.servers.linodedb.net') - self.assertEqual(dbs[0].hosts.secondary, 'lin-123-456-mysql-primary-private.servers.linodedb.net') + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, '8.0.26') + self.assertEqual(dbs[0].version, "8.0.26") def test_database_instance(self): """ @@ -78,65 +84,71 @@ def test_get_instances(self): dbs = self.client.database.mysql_instances() self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") self.assertEqual(dbs[0].cluster_size, 3) self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, 'mysql') - self.assertEqual(dbs[0].hosts.primary, 'lin-123-456-mysql-mysql-primary.servers.linodedb.net') - self.assertEqual(dbs[0].hosts.secondary, 'lin-123-456-mysql-primary-private.servers.linodedb.net') + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, '8.0.26') + self.assertEqual(dbs[0].version, "8.0.26") def test_create(self): """ Test that MySQL databases can be created """ - with self.mock_post('/databases/mysql/instances') as m: + with self.mock_post("/databases/mysql/instances") as m: # We don't care about errors here; we just want to # validate the request. try: self.client.database.mysql_create( - 'cool', - 'us-southeast', - 'mysql/8.0.26', - 'g6-standard-1', - cluster_size=3 + "cool", + "us-southeast", + "mysql/8.0.26", + "g6-standard-1", + cluster_size=3, ) except Exception: pass - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mysql/instances') - self.assertEqual(m.call_data['label'], 'cool') - self.assertEqual(m.call_data['region'], 'us-southeast') - self.assertEqual(m.call_data['engine'], 'mysql/8.0.26') - self.assertEqual(m.call_data['type'], 'g6-standard-1') - self.assertEqual(m.call_data['cluster_size'], 3) + self.assertEqual(m.method, "post") + self.assertEqual(m.call_url, "/databases/mysql/instances") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["region"], "us-southeast") + self.assertEqual(m.call_data["engine"], "mysql/8.0.26") + self.assertEqual(m.call_data["type"], "g6-standard-1") + self.assertEqual(m.call_data["cluster_size"], 3) def test_update(self): """ Test that the MySQL database can be updated """ - with self.mock_put('/databases/mysql/instances/123') as m: - new_allow_list = ['192.168.0.1/32'] + with self.mock_put("/databases/mysql/instances/123") as m: + new_allow_list = ["192.168.0.1/32"] db = MySQLDatabase(self.client, 123) db.updates.day_of_week = 2 db.allow_list = new_allow_list - db.label = 'cool' + db.label = "cool" db.save() - self.assertEqual(m.method, 'put') - self.assertEqual(m.call_url, '/databases/mysql/instances/123') - self.assertEqual(m.call_data['label'], 'cool') - self.assertEqual(m.call_data['updates']['day_of_week'], 2) - self.assertEqual(m.call_data['allow_list'], new_allow_list) + self.assertEqual(m.method, "put") + self.assertEqual(m.call_url, "/databases/mysql/instances/123") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["updates"]["day_of_week"], 2) + self.assertEqual(m.call_data["allow_list"], new_allow_list) def test_list_backups(self): """ @@ -149,53 +161,61 @@ def test_list_backups(self): self.assertEqual(len(backups), 1) self.assertEqual(backups[0].id, 456) - self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI') - self.assertEqual(backups[0].type, 'auto') + self.assertEqual( + backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" + ) + self.assertEqual(backups[0].type, "auto") def test_create_backup(self): """ Test that MySQL database backups can be updated """ - with self.mock_post('/databases/mysql/instances/123/backups') as m: + with self.mock_post("/databases/mysql/instances/123/backups") as m: db = MySQLDatabase(self.client, 123) # We don't care about errors here; we just want to # validate the request. try: - db.backup_create('mybackup', target='secondary') + db.backup_create("mybackup", target="secondary") except Exception: pass - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups') - self.assertEqual(m.call_data['label'], 'mybackup') - self.assertEqual(m.call_data['target'], 'secondary') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/backups" + ) + self.assertEqual(m.call_data["label"], "mybackup") + self.assertEqual(m.call_data["target"], "secondary") def test_backup_restore(self): """ Test that MySQL database backups can be restored """ - with self.mock_post('/databases/mysql/instances/123/backups/456/restore') as m: + with self.mock_post( + "/databases/mysql/instances/123/backups/456/restore" + ) as m: db = MySQLDatabase(self.client, 123) db.backups[0].restore() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mysql/instances/123/backups/456/restore') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/backups/456/restore" + ) def test_patch(self): """ Test MySQL Database patching logic. """ - with self.mock_post('/databases/mysql/instances/123/patch') as m: + with self.mock_post("/databases/mysql/instances/123/patch") as m: db = MySQLDatabase(self.client, 123) db.patch() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mysql/instances/123/patch') + self.assertEqual(m.method, "post") + self.assertEqual(m.call_url, "/databases/mysql/instances/123/patch") def test_get_ssl(self): """ @@ -205,7 +225,7 @@ def test_get_ssl(self): ssl = db.ssl - self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==') + self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==") def test_get_credentials(self): """ @@ -215,20 +235,24 @@ def test_get_credentials(self): creds = db.credentials - self.assertEqual(creds.password, 's3cur3P@ssw0rd') - self.assertEqual(creds.username, 'linroot') + self.assertEqual(creds.password, "s3cur3P@ssw0rd") + self.assertEqual(creds.username, "linroot") def test_reset_credentials(self): """ Test resetting MySQL credentials """ - with self.mock_post('/databases/mysql/instances/123/credentials/reset') as m: + with self.mock_post( + "/databases/mysql/instances/123/credentials/reset" + ) as m: db = MySQLDatabase(self.client, 123) db.credentials_reset() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mysql/instances/123/credentials/reset') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/credentials/reset" + ) class PostgreSQLDatabaseTest(ClientBaseCase): @@ -243,65 +267,71 @@ def test_get_instances(self): dbs = self.client.database.postgresql_instances() self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") self.assertEqual(dbs[0].cluster_size, 3) self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, 'postgresql') - self.assertEqual(dbs[0].hosts.primary, 'lin-0000-000-pgsql-primary.servers.linodedb.net') - self.assertEqual(dbs[0].hosts.secondary, 'lin-0000-000-pgsql-primary-private.servers.linodedb.net') + self.assertEqual(dbs[0].engine, "postgresql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-0000-000-pgsql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-0000-000-pgsql-primary-private.servers.linodedb.net", + ) self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, '13.2') + self.assertEqual(dbs[0].version, "13.2") def test_create(self): """ Test that PostgreSQL databases can be created """ - with self.mock_post('/databases/postgresql/instances') as m: + with self.mock_post("/databases/postgresql/instances") as m: # We don't care about errors here; we just want to # validate the request. try: self.client.database.postgresql_create( - 'cool', - 'us-southeast', - 'postgresql/13.2', - 'g6-standard-1', - cluster_size=3 + "cool", + "us-southeast", + "postgresql/13.2", + "g6-standard-1", + cluster_size=3, ) except Exception: pass - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/postgresql/instances') - self.assertEqual(m.call_data['label'], 'cool') - self.assertEqual(m.call_data['region'], 'us-southeast') - self.assertEqual(m.call_data['engine'], 'postgresql/13.2') - self.assertEqual(m.call_data['type'], 'g6-standard-1') - self.assertEqual(m.call_data['cluster_size'], 3) + self.assertEqual(m.method, "post") + self.assertEqual(m.call_url, "/databases/postgresql/instances") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["region"], "us-southeast") + self.assertEqual(m.call_data["engine"], "postgresql/13.2") + self.assertEqual(m.call_data["type"], "g6-standard-1") + self.assertEqual(m.call_data["cluster_size"], 3) def test_update(self): """ Test that the PostgreSQL database can be updated """ - with self.mock_put('/databases/postgresql/instances/123') as m: - new_allow_list = ['192.168.0.1/32'] + with self.mock_put("/databases/postgresql/instances/123") as m: + new_allow_list = ["192.168.0.1/32"] db = PostgreSQLDatabase(self.client, 123) db.updates.day_of_week = 2 db.allow_list = new_allow_list - db.label = 'cool' + db.label = "cool" db.save() - self.assertEqual(m.method, 'put') - self.assertEqual(m.call_url, '/databases/postgresql/instances/123') - self.assertEqual(m.call_data['label'], 'cool') - self.assertEqual(m.call_data['updates']['day_of_week'], 2) - self.assertEqual(m.call_data['allow_list'], new_allow_list) + self.assertEqual(m.method, "put") + self.assertEqual(m.call_url, "/databases/postgresql/instances/123") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["updates"]["day_of_week"], 2) + self.assertEqual(m.call_data["allow_list"], new_allow_list) def test_list_backups(self): """ @@ -314,53 +344,64 @@ def test_list_backups(self): self.assertEqual(len(backups), 1) self.assertEqual(backups[0].id, 456) - self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI') - self.assertEqual(backups[0].type, 'auto') + self.assertEqual( + backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" + ) + self.assertEqual(backups[0].type, "auto") def test_create_backup(self): """ Test that PostgreSQL database backups can be created """ - with self.mock_post('/databases/postgresql/instances/123/backups') as m: + with self.mock_post("/databases/postgresql/instances/123/backups") as m: db = PostgreSQLDatabase(self.client, 123) # We don't care about errors here; we just want to # validate the request. try: - db.backup_create('mybackup', target='secondary') + db.backup_create("mybackup", target="secondary") except Exception: pass - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/postgresql/instances/123/backups') - self.assertEqual(m.call_data['label'], 'mybackup') - self.assertEqual(m.call_data['target'], 'secondary') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/backups" + ) + self.assertEqual(m.call_data["label"], "mybackup") + self.assertEqual(m.call_data["target"], "secondary") def test_backup_restore(self): """ Test that PostgreSQL database backups can be restored """ - with self.mock_post('/databases/postgresql/instances/123/backups/456/restore') as m: + with self.mock_post( + "/databases/postgresql/instances/123/backups/456/restore" + ) as m: db = PostgreSQLDatabase(self.client, 123) db.backups[0].restore() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/postgresql/instances/123/backups/456/restore') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, + "/databases/postgresql/instances/123/backups/456/restore", + ) def test_patch(self): """ Test PostgreSQL Database patching logic. """ - with self.mock_post('/databases/postgresql/instances/123/patch') as m: + with self.mock_post("/databases/postgresql/instances/123/patch") as m: db = PostgreSQLDatabase(self.client, 123) db.patch() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/postgresql/instances/123/patch') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/patch" + ) def test_get_ssl(self): """ @@ -370,7 +411,7 @@ def test_get_ssl(self): ssl = db.ssl - self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==') + self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==") def test_get_credentials(self): """ @@ -380,20 +421,25 @@ def test_get_credentials(self): creds = db.credentials - self.assertEqual(creds.password, 's3cur3P@ssw0rd') - self.assertEqual(creds.username, 'linroot') + self.assertEqual(creds.password, "s3cur3P@ssw0rd") + self.assertEqual(creds.username, "linroot") def test_reset_credentials(self): """ Test resetting PostgreSQL credentials """ - with self.mock_post('/databases/postgresql/instances/123/credentials/reset') as m: + with self.mock_post( + "/databases/postgresql/instances/123/credentials/reset" + ) as m: db = PostgreSQLDatabase(self.client, 123) db.credentials_reset() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/postgresql/instances/123/credentials/reset') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, + "/databases/postgresql/instances/123/credentials/reset", + ) class MongoDBDatabaseTest(ClientBaseCase): @@ -408,67 +454,69 @@ def test_get_instances(self): dbs = self.client.database.mongodb_instances() self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], '192.0.1.0/24') + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].compression_type, 'none') + self.assertEqual(dbs[0].compression_type, "none") self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, 'mongodb') - self.assertEqual(dbs[0].hosts.primary, 'lin-0000-0000.servers.linodedb.net') + self.assertEqual(dbs[0].engine, "mongodb") + self.assertEqual( + dbs[0].hosts.primary, "lin-0000-0000.servers.linodedb.net" + ) self.assertEqual(dbs[0].hosts.secondary, None) self.assertEqual(len(dbs[0].peers), 3) self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, 'us-east') + self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, '4.4.10') + self.assertEqual(dbs[0].version, "4.4.10") def test_create(self): """ Test that MongoDB databases can be created """ - with self.mock_post('/databases/mongodb/instances') as m: + with self.mock_post("/databases/mongodb/instances") as m: # We don't care about errors here; we just want to # validate the request. try: self.client.database.mongodb_create( - 'cool', - 'us-southeast', - 'mongodb/4.4.10', - 'g6-standard-1', - cluster_size=3 + "cool", + "us-southeast", + "mongodb/4.4.10", + "g6-standard-1", + cluster_size=3, ) except Exception: pass - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mongodb/instances') - self.assertEqual(m.call_data['label'], 'cool') - self.assertEqual(m.call_data['region'], 'us-southeast') - self.assertEqual(m.call_data['engine'], 'mongodb/4.4.10') - self.assertEqual(m.call_data['type'], 'g6-standard-1') - self.assertEqual(m.call_data['cluster_size'], 3) + self.assertEqual(m.method, "post") + self.assertEqual(m.call_url, "/databases/mongodb/instances") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["region"], "us-southeast") + self.assertEqual(m.call_data["engine"], "mongodb/4.4.10") + self.assertEqual(m.call_data["type"], "g6-standard-1") + self.assertEqual(m.call_data["cluster_size"], 3) def test_update(self): """ Test that the MongoDB database can be updated """ - with self.mock_put('/databases/mongodb/instances/123') as m: - new_allow_list = ['192.168.0.1/32'] + with self.mock_put("/databases/mongodb/instances/123") as m: + new_allow_list = ["192.168.0.1/32"] db = MongoDBDatabase(self.client, 123) db.updates.day_of_week = 2 db.allow_list = new_allow_list - db.label = 'cool' + db.label = "cool" db.save() - self.assertEqual(m.method, 'put') - self.assertEqual(m.call_url, '/databases/mongodb/instances/123') - self.assertEqual(m.call_data['label'], 'cool') - self.assertEqual(m.call_data['updates']['day_of_week'], 2) - self.assertEqual(m.call_data['allow_list'], new_allow_list) + self.assertEqual(m.method, "put") + self.assertEqual(m.call_url, "/databases/mongodb/instances/123") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["updates"]["day_of_week"], 2) + self.assertEqual(m.call_data["allow_list"], new_allow_list) def test_list_backups(self): """ @@ -481,53 +529,64 @@ def test_list_backups(self): self.assertEqual(len(backups), 1) self.assertEqual(backups[0].id, 456) - self.assertEqual(backups[0].label, 'Scheduled - 02/04/22 11:11 UTC-XcCRmI') - self.assertEqual(backups[0].type, 'auto') + self.assertEqual( + backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" + ) + self.assertEqual(backups[0].type, "auto") def test_create_backup(self): """ Test that MongoDB database backups can be created """ - with self.mock_post('/databases/mongodb/instances/123/backups') as m: + with self.mock_post("/databases/mongodb/instances/123/backups") as m: db = MongoDBDatabase(self.client, 123) # We don't care about errors here; we just want to # validate the request. try: - db.backup_create('mybackup', target='secondary') + db.backup_create("mybackup", target="secondary") except Exception: pass - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mongodb/instances/123/backups') - self.assertEqual(m.call_data['label'], 'mybackup') - self.assertEqual(m.call_data['target'], 'secondary') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mongodb/instances/123/backups" + ) + self.assertEqual(m.call_data["label"], "mybackup") + self.assertEqual(m.call_data["target"], "secondary") def test_backup_restore(self): """ Test that MongoDB database backups can be restored """ - with self.mock_post('/databases/mongodb/instances/123/backups/456/restore') as m: + with self.mock_post( + "/databases/mongodb/instances/123/backups/456/restore" + ) as m: db = MongoDBDatabase(self.client, 123) db.backups[0].restore() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mongodb/instances/123/backups/456/restore') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, + "/databases/mongodb/instances/123/backups/456/restore", + ) def test_patch(self): """ Test MongoDB Database patching logic. """ - with self.mock_post('/databases/mongodb/instances/123/patch') as m: + with self.mock_post("/databases/mongodb/instances/123/patch") as m: db = MongoDBDatabase(self.client, 123) db.patch() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mongodb/instances/123/patch') + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mongodb/instances/123/patch" + ) def test_get_ssl(self): """ @@ -537,7 +596,7 @@ def test_get_ssl(self): ssl = db.ssl - self.assertEqual(ssl.ca_certificate, 'LS0tLS1CRUdJ...==') + self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==") def test_get_credentials(self): """ @@ -547,17 +606,21 @@ def test_get_credentials(self): creds = db.credentials - self.assertEqual(creds.password, 's3cur3P@ssw0rd') - self.assertEqual(creds.username, 'linroot') + self.assertEqual(creds.password, "s3cur3P@ssw0rd") + self.assertEqual(creds.username, "linroot") def test_reset_credentials(self): """ Test resetting MongoDB credentials """ - with self.mock_post('/databases/mongodb/instances/123/credentials/reset') as m: + with self.mock_post( + "/databases/mongodb/instances/123/credentials/reset" + ) as m: db = MongoDBDatabase(self.client, 123) db.credentials_reset() - self.assertEqual(m.method, 'post') - self.assertEqual(m.call_url, '/databases/mongodb/instances/123/credentials/reset') \ No newline at end of file + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mongodb/instances/123/credentials/reset" + ) diff --git a/test/objects/firewall_test.py b/test/objects/firewall_test.py index 0a0f27448..3b8cc280b 100644 --- a/test/objects/firewall_test.py +++ b/test/objects/firewall_test.py @@ -2,10 +2,12 @@ from linode_api4.objects import Firewall, FirewallDevice + class FirewallTest(ClientBaseCase): """ Tests methods of the Firewall class """ + def test_get_rules(self): """ Test that the rules can be retrieved from a Firewall @@ -14,9 +16,9 @@ def test_get_rules(self): rules = firewall.rules self.assertEqual(len(rules.inbound), 0) - self.assertEqual(rules.inbound_policy, 'DROP') + self.assertEqual(rules.inbound_policy, "DROP") self.assertEqual(len(rules.outbound), 0) - self.assertEqual(rules.outbound_policy, 'DROP') + self.assertEqual(rules.outbound_policy, "DROP") def test_update_rules(self): """ @@ -25,34 +27,30 @@ def test_update_rules(self): firewall = Firewall(self.client, 123) - with self.mock_put('networking/firewalls/123/rules') as m: + with self.mock_put("networking/firewalls/123/rules") as m: new_rules = { - 'inbound': [ + "inbound": [ { - 'action': 'ACCEPT', - 'addresses': { - 'ipv4': [ - '0.0.0.0/0' - ], - 'ipv6': [ - "ff00::/8" - ] + "action": "ACCEPT", + "addresses": { + "ipv4": ["0.0.0.0/0"], + "ipv6": ["ff00::/8"], }, - 'description': 'A really cool firewall rule.', - 'label': 'really-cool-firewall-rule', - 'ports': '80', - 'protocol': 'TCP' + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP", } ], - 'inbound_policy': 'ALLOW', - 'outbound': [], - 'outbound_policy': 'ALLOW' + "inbound_policy": "ALLOW", + "outbound": [], + "outbound_policy": "ALLOW", } firewall.update_rules(new_rules) - self.assertEqual(m.method, 'put') - self.assertEqual(m.call_url, '/networking/firewalls/123/rules') + self.assertEqual(m.method, "put") + self.assertEqual(m.call_url, "/networking/firewalls/123/rules") self.assertEqual(m.call_data, new_rules) @@ -61,6 +59,7 @@ class FirewallDevicesTest(ClientBaseCase): """ Tests methods of Firewall devices """ + def test_get_devices(self): """ Tests that devices can be pulled from a firewall @@ -77,8 +76,8 @@ def test_get_device(self): self.assertEqual(device.id, 123) self.assertEqual(device.entity.id, 123) - self.assertEqual(device.entity.label, 'my-linode') - self.assertEqual(device.entity.type, 'linode') - self.assertEqual(device.entity.url, '/v4/linode/instances/123') + self.assertEqual(device.entity.label, "my-linode") + self.assertEqual(device.entity.type, "linode") + self.assertEqual(device.entity.url, "/v4/linode/instances/123") self.assertEqual(device._populated, True) diff --git a/test/objects/image_test.py b/test/objects/image_test.py index 6e9710646..02f392473 100644 --- a/test/objects/image_test.py +++ b/test/objects/image_test.py @@ -1,13 +1,11 @@ from datetime import datetime from io import BytesIO -from typing import Any, BinaryIO -from unittest.mock import patch - from test.base import ClientBaseCase +from typing import BinaryIO +from unittest.mock import patch from linode_api4.objects import Image - # A minimal gzipped image that will be accepted by the API TEST_IMAGE_CONTENT = ( b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" @@ -19,17 +17,18 @@ class ImageTest(ClientBaseCase): """ Tests methods of the Image class """ + def test_get_image(self): """ Tests that an image is loaded correctly by ID """ - image = Image(self.client, 'linode/debian9') + image = Image(self.client, "linode/debian9") self.assertEqual(image._populated, False) - self.assertEqual(image.label, 'Debian 9') + self.assertEqual(image.label, "Debian 9") self.assertEqual(image._populated, True) - self.assertEqual(image.vendor, 'Debian') + self.assertEqual(image.vendor, "Debian") self.assertEqual(image.description, None) self.assertEqual(image.deprecated, False) self.assertEqual(image.status, "available") @@ -37,17 +36,20 @@ def test_get_image(self): self.assertEqual(image.created_by, "linode") self.assertEqual(image.size, 1100) - self.assertEqual(image.eol, datetime( - year=2026, month=7, day=1, hour=4, minute=0, second=0 - )) + self.assertEqual( + image.eol, + datetime(year=2026, month=7, day=1, hour=4, minute=0, second=0), + ) - self.assertEqual(image.expiry, datetime( - year=2026, month=8, day=1, hour=4, minute=0, second=0 - )) + self.assertEqual( + image.expiry, + datetime(year=2026, month=8, day=1, hour=4, minute=0, second=0), + ) - self.assertEqual(image.updated, datetime( - year=2020, month=7, day=1, hour=4, minute=0, second=0 - )) + self.assertEqual( + image.updated, + datetime(year=2020, month=7, day=1, hour=4, minute=0, second=0), + ) def test_image_create_upload(self): """ @@ -68,8 +70,8 @@ def test_image_create_upload(self): { "label": "Realest Image Upload", "region": "us-southeast", - "description": "very real image upload." - } + "description": "very real image upload.", + }, ) self.assertEqual(image.id, "private/1337") @@ -97,4 +99,4 @@ def put_mock(url: str, data: BinaryIO = None, **kwargs): self.assertEqual(image.id, "private/1337") self.assertEqual(image.label, "Realest Image Upload") - self.assertEqual(image.description, "very real image upload.") \ No newline at end of file + self.assertEqual(image.description, "very real image upload.") diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index 6eb18703e..8f397289a 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -3,10 +3,12 @@ from linode_api4.objects import Config, Disk, Image, Instance, Type + class LinodeTest(ClientBaseCase): """ Tests methods of the Linode class """ + def test_get_linode(self): """ Tests that a client is loaded correctly by ID @@ -19,18 +21,20 @@ def test_get_linode(self): self.assertTrue(isinstance(linode.image, Image)) self.assertEqual(linode.image.label, "Ubuntu 17.04") - self.assertEqual(linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8") + self.assertEqual( + linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8" + ) self.assertEqual(linode.watchdog_enabled, True) json = linode._raw_json self.assertIsNotNone(json) - self.assertEqual(json['id'], 123) - self.assertEqual(json['label'], 'linode123') - self.assertEqual(json['group'], 'test') + self.assertEqual(json["id"], 123) + self.assertEqual(json["label"], "linode123") + self.assertEqual(json["group"], "test") # test that the _raw_json stored on the object is sufficient to populate # a new object - linode2 = Instance(self.client, json['id'], json=json) + linode2 = Instance(self.client, json["id"], json=json) self.assertTrue(linode2._populated) self.assertEqual(linode2.id, linode.id) @@ -56,18 +60,21 @@ def test_rebuild(self): """ linode = Instance(self.client, 123) - with self.mock_post('/linode/instances/123') as m: - pw = linode.rebuild('linode/debian9') + with self.mock_post("/linode/instances/123") as m: + pw = linode.rebuild("linode/debian9") self.assertIsNotNone(pw) self.assertTrue(isinstance(pw, str)) - self.assertEqual(m.call_url, '/linode/instances/123/rebuild') + self.assertEqual(m.call_url, "/linode/instances/123/rebuild") - self.assertEqual(m.call_data, { - "image": "linode/debian9", - "root_pass": pw, - }) + self.assertEqual( + m.call_data, + { + "image": "linode/debian9", + "root_pass": pw, + }, + ) def test_available_backups(self): """ @@ -84,29 +91,35 @@ def test_available_backups(self): b = backups.automatic[0] self.assertEqual(b.id, 12345) self.assertEqual(b._populated, True) - self.assertEqual(b.status, 'successful') - self.assertEqual(b.type, 'auto') - self.assertEqual(b.created, datetime(year=2018, month=1, day=9, hour=0, - minute=1, second=1)) - self.assertEqual(b.updated, datetime(year=2018, month=1, day=9, hour=0, - minute=1, second=1)) - self.assertEqual(b.finished, datetime(year=2018, month=1, day=9, hour=0, - minute=1, second=1)) - self.assertEqual(b.region.id, 'us-east-1a') + self.assertEqual(b.status, "successful") + self.assertEqual(b.type, "auto") + self.assertEqual( + b.created, + datetime(year=2018, month=1, day=9, hour=0, minute=1, second=1), + ) + self.assertEqual( + b.updated, + datetime(year=2018, month=1, day=9, hour=0, minute=1, second=1), + ) + self.assertEqual( + b.finished, + datetime(year=2018, month=1, day=9, hour=0, minute=1, second=1), + ) + self.assertEqual(b.region.id, "us-east-1a") self.assertEqual(b.label, None) self.assertEqual(b.message, None) self.assertEqual(b.available, True) self.assertEqual(len(b.disks), 2) self.assertEqual(b.disks[0].size, 1024) - self.assertEqual(b.disks[0].label, 'Debian 8.1 Disk') - self.assertEqual(b.disks[0].filesystem, 'ext4') + self.assertEqual(b.disks[0].label, "Debian 8.1 Disk") + self.assertEqual(b.disks[0].filesystem, "ext4") self.assertEqual(b.disks[1].size, 0) - self.assertEqual(b.disks[1].label, '256MB Swap Image') - self.assertEqual(b.disks[1].filesystem, 'swap') + self.assertEqual(b.disks[1].label, "256MB Swap Image") + self.assertEqual(b.disks[1].filesystem, "swap") self.assertEqual(len(b.configs), 1) - self.assertEqual(b.configs[0], 'My Debian 8.1 Profile') + self.assertEqual(b.configs[0], "My Debian 8.1 Profile") # assert that snapshots came back as expected self.assertEqual(backups.snapshot.current, None) @@ -116,26 +129,29 @@ def test_update_linode(self): """ Tests that a Linode can be updated """ - with self.mock_put('linode/instances/123') as m: + with self.mock_put("linode/instances/123") as m: linode = self.client.load(Instance, 123) linode.label = "NewLinodeLabel" linode.group = "new_group" linode.save() - self.assertEqual(m.call_url, '/linode/instances/123') - self.assertEqual(m.call_data, { - "alerts": { - "cpu": 90, - "io": 5000, - "network_in": 5, - "network_out": 5, - "transfer_quota": 80 + self.assertEqual(m.call_url, "/linode/instances/123") + self.assertEqual( + m.call_data, + { + "alerts": { + "cpu": 90, + "io": 5000, + "network_in": 5, + "network_out": 5, + "transfer_quota": 80, + }, + "label": "NewLinodeLabel", + "group": "new_group", + "tags": ["something"], }, - "label": "NewLinodeLabel", - "group": "new_group", - "tags": ["something"], - }) + ) def test_delete_linode(self): """ @@ -145,7 +161,7 @@ def test_delete_linode(self): linode = Instance(self.client, 123) linode.delete() - self.assertEqual(m.call_url, '/linode/instances/123') + self.assertEqual(m.call_url, "/linode/instances/123") def test_reboot(self): """ @@ -156,7 +172,7 @@ def test_reboot(self): with self.mock_post(result) as m: linode.reboot() - self.assertEqual(m.call_url, '/linode/instances/123/reboot') + self.assertEqual(m.call_url, "/linode/instances/123/reboot") def test_shutdown(self): """ @@ -167,7 +183,7 @@ def test_shutdown(self): with self.mock_post(result) as m: linode.shutdown() - self.assertEqual(m.call_url, '/linode/instances/123/shutdown') + self.assertEqual(m.call_url, "/linode/instances/123/shutdown") def test_boot(self): """ @@ -178,7 +194,7 @@ def test_boot(self): with self.mock_post(result) as m: linode.boot() - self.assertEqual(m.call_url, '/linode/instances/123/boot') + self.assertEqual(m.call_url, "/linode/instances/123/boot") def test_resize(self): """ @@ -188,22 +204,22 @@ def test_resize(self): result = {} with self.mock_post(result) as m: - linode.resize(new_type='g6-standard-1') - self.assertEqual(m.call_url, '/linode/instances/123/resize') - self.assertEqual(m.call_data, {'type': 'g6-standard-1'}) + linode.resize(new_type="g6-standard-1") + self.assertEqual(m.call_url, "/linode/instances/123/resize") + self.assertEqual(m.call_data, {"type": "g6-standard-1"}) def test_resize_with_class(self): """ Tests that you can submit a correct resize api request with a Base class type """ linode = Instance(self.client, 123) - ltype = Type(self.client, 'g6-standard-2') + ltype = Type(self.client, "g6-standard-2") result = {} with self.mock_post(result) as m: linode.resize(new_type=ltype) - self.assertEqual(m.call_url, '/linode/instances/123/resize') - self.assertEqual(m.call_data, {'type': 'g6-standard-2'}) + self.assertEqual(m.call_url, "/linode/instances/123/resize") + self.assertEqual(m.call_data, {"type": "g6-standard-2"}) def test_boot_with_config(self): """ @@ -215,7 +231,7 @@ def test_boot_with_config(self): with self.mock_post(result) as m: linode.boot(config=config) - self.assertEqual(m.call_url, '/linode/instances/123/boot') + self.assertEqual(m.call_url, "/linode/instances/123/boot") def test_mutate(self): """ @@ -226,7 +242,7 @@ def test_mutate(self): with self.mock_post(result) as m: linode.mutate() - self.assertEqual(m.call_url, '/linode/instances/123/mutate') + self.assertEqual(m.call_url, "/linode/instances/123/mutate") self.assertEqual(m.call_data["allow_auto_disk_resize"], True) def test_firewalls(self): @@ -235,9 +251,9 @@ def test_firewalls(self): """ linode = Instance(self.client, 123) - with self.mock_get('/linode/instances/123/firewalls') as m: + with self.mock_get("/linode/instances/123/firewalls") as m: result = linode.firewalls() - self.assertEqual(m.call_url, '/linode/instances/123/firewalls') + self.assertEqual(m.call_url, "/linode/instances/123/firewalls") self.assertEquals(len(result), 1) def test_volumes(self): @@ -246,9 +262,9 @@ def test_volumes(self): """ linode = Instance(self.client, 123) - with self.mock_get('/linode/instances/123/volumes') as m: + with self.mock_get("/linode/instances/123/volumes") as m: result = linode.volumes() - self.assertEqual(m.call_url, '/linode/instances/123/volumes') + self.assertEqual(m.call_url, "/linode/instances/123/volumes") self.assertEquals(len(result), 1) def test_nodebalancers(self): @@ -257,9 +273,9 @@ def test_nodebalancers(self): """ linode = Instance(self.client, 123) - with self.mock_get('/linode/instances/123/nodebalancers') as m: + with self.mock_get("/linode/instances/123/nodebalancers") as m: result = linode.nodebalancers() - self.assertEqual(m.call_url, '/linode/instances/123/nodebalancers') + self.assertEqual(m.call_url, "/linode/instances/123/nodebalancers") self.assertEquals(len(result), 1) def test_transfer_year_month(self): @@ -268,9 +284,11 @@ def test_transfer_year_month(self): """ linode = Instance(self.client, 123) - with self.mock_get('/linode/instances/123/transfer/2023/4') as m: + with self.mock_get("/linode/instances/123/transfer/2023/4") as m: linode.transfer_year_month(2023, 4) - self.assertEqual(m.call_url, '/linode/instances/123/transfer/2023/4') + self.assertEqual( + m.call_url, "/linode/instances/123/transfer/2023/4" + ) def test_duplicate(self): """ @@ -280,7 +298,9 @@ def test_duplicate(self): with self.mock_post("/linode/instances/123/disks/12345/clone") as m: disk.duplicate() - self.assertEqual(m.call_url, '/linode/instances/123/disks/12345/clone') + self.assertEqual( + m.call_url, "/linode/instances/123/disks/12345/clone" + ) def test_disk_password(self): """ @@ -290,7 +310,9 @@ def test_disk_password(self): with self.mock_post({}) as m: disk.reset_root_password() - self.assertEqual(m.call_url, '/linode/instances/123/disks/12345/password') + self.assertEqual( + m.call_url, "/linode/instances/123/disks/12345/password" + ) def test_instance_password(self): """ @@ -300,7 +322,7 @@ def test_instance_password(self): with self.mock_post({}) as m: instance.reset_instance_root_password() - self.assertEqual(m.call_url, '/linode/instances/123/password') + self.assertEqual(m.call_url, "/linode/instances/123/password") def test_ips(self): """ @@ -320,8 +342,6 @@ def test_ips(self): self.assertIsNotNone(ips.ipv6.link_local) self.assertIsNotNone(ips.ipv6.pools) - - def test_initiate_migration(self): """ Tests that you can initiate a pending migration @@ -331,7 +351,7 @@ def test_initiate_migration(self): with self.mock_post(result) as m: linode.initiate_migration() - self.assertEqual(m.call_url, '/linode/instances/123/migrate') + self.assertEqual(m.call_url, "/linode/instances/123/migrate") def test_create_disk(self): """ @@ -340,17 +360,25 @@ def test_create_disk(self): linode = Instance(self.client, 123) with self.mock_post("/linode/instances/123/disks/12345") as m: - disk, gen_pass = linode.disk_create(1234, label="test", authorized_users=["test"], image="linode/debian10") + disk, gen_pass = linode.disk_create( + 1234, + label="test", + authorized_users=["test"], + image="linode/debian10", + ) self.assertEqual(m.call_url, "/linode/instances/123/disks") print(m.call_data) - self.assertEqual(m.call_data, { - "size": 1234, - "label": "test", - "root_pass": gen_pass, - "image": "linode/debian10", - "authorized_users": ["test"], - "read_only": False, - }) + self.assertEqual( + m.call_data, + { + "size": 1234, + "label": "test", + "root_pass": gen_pass, + "image": "linode/debian10", + "authorized_users": ["test"], + "read_only": False, + }, + ) assert disk.id == 12345 @@ -359,6 +387,7 @@ class DiskTest(ClientBaseCase): """ Tests for the Disk object """ + def test_resize(self): """ Tests that a resize is submitted correctly @@ -370,7 +399,9 @@ def test_resize(self): self.assertTrue(r) - self.assertEqual(m.call_url, '/linode/instances/123/disks/12345/resize') + self.assertEqual( + m.call_url, "/linode/instances/123/disks/12345/resize" + ) self.assertEqual(m.call_data, {"size": 1000}) @@ -384,37 +415,34 @@ def test_update_interfaces(self): Tests that a configs interfaces update correctly """ - json = self.client.get('/linode/instances/123/configs/456789') + json = self.client.get("/linode/instances/123/configs/456789") config = Config(self.client, 456789, 123, json=json) - with self.mock_put('/linode/instances/123/configs/456789') as m: + with self.mock_put("/linode/instances/123/configs/456789") as m: new_interfaces = [ - { - 'purpose': 'public' - }, - { - 'purpose': 'vlan', - 'label': 'cool-vlan' - } + {"purpose": "public"}, + {"purpose": "vlan", "label": "cool-vlan"}, ] config.interfaces = new_interfaces config.save() - self.assertEqual(m.call_url, '/linode/instances/123/configs/456789') - self.assertEqual(m.call_data.get('interfaces'), new_interfaces) + self.assertEqual(m.call_url, "/linode/instances/123/configs/456789") + self.assertEqual(m.call_data.get("interfaces"), new_interfaces) def test_get_config(self): - json = self.client.get('/linode/instances/123/configs/456789') + json = self.client.get("/linode/instances/123/configs/456789") config = Config(self.client, 456789, 123, json=json) self.assertEqual(config.root_device, "/dev/sda") self.assertEqual(config.comments, "") self.assertIsNotNone(config.helpers) self.assertEqual(config.label, "My Ubuntu 17.04 LTS Profile") - self.assertEqual(config.created, datetime(year=2014, month=10, day=7, hour=20, - minute=4, second=0)) + self.assertEqual( + config.created, + datetime(year=2014, month=10, day=7, hour=20, minute=4, second=0), + ) self.assertEqual(config.memory_limit, 0) self.assertEqual(config.id, 456789) self.assertIsNotNone(config.interfaces) @@ -423,6 +451,7 @@ def test_get_config(self): self.assertEqual(config.virt_mode, "paravirt") self.assertIsNotNone(config.devices) + class TypeTest(ClientBaseCase): def test_get_types(self): """ @@ -444,14 +473,14 @@ def test_get_type_by_id(self): """ Tests that a Linode type is loaded correctly by ID """ - t = Type(self.client, 'g5-nanode-1') + t = Type(self.client, "g5-nanode-1") self.assertEqual(t._populated, False) self.assertEqual(t.vcpus, 1) self.assertEqual(t.gpus, 0) self.assertEqual(t.label, "Linode 1024") self.assertEqual(t.disk, 20480) - self.assertEqual(t.type_class, 'nanode') + self.assertEqual(t.type_class, "nanode") def test_get_type_gpu(self): """ @@ -505,4 +534,3 @@ def test_save_force(self): with self.mock_put("linode/instances") as m: linode.save() assert m.called - diff --git a/test/objects/lke_test.py b/test/objects/lke_test.py index a2721962e..c42a3de2a 100644 --- a/test/objects/lke_test.py +++ b/test/objects/lke_test.py @@ -3,6 +3,7 @@ from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode + class LKETest(ClientBaseCase): """ Tests methods of the LKE class @@ -16,8 +17,14 @@ def test_get_cluster(self): cluster = LKECluster(self.client, 18881) self.assertEqual(cluster.id, 18881) - self.assertEqual(cluster.created, datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21)) - self.assertEqual(cluster.updated, datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21)) + self.assertEqual( + cluster.created, + datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21), + ) + self.assertEqual( + cluster.updated, + datetime(year=2021, month=2, day=10, hour=23, minute=54, second=21), + ) self.assertEqual(cluster.label, "example-cluster") self.assertEqual(cluster.tags, []) self.assertEqual(cluster.region.id, "ap-west") @@ -45,9 +52,9 @@ def test_cluster_dashboard_url_view(self): """ cluster = LKECluster(self.client, 18881) - with self.mock_get('/lke/clusters/18881/dashboard') as m: + with self.mock_get("/lke/clusters/18881/dashboard") as m: result = cluster.cluster_dashboard_url_view() - self.assertEqual(m.call_url, '/lke/clusters/18881/dashboard') + self.assertEqual(m.call_url, "/lke/clusters/18881/dashboard") self.assertEqual(result, "https://example.dashboard.linodelke.net") def test_kubeconfig_delete(self): @@ -58,7 +65,7 @@ def test_kubeconfig_delete(self): with self.mock_delete() as m: cluster.kubeconfig_delete() - self.assertEqual(m.call_url, '/lke/clusters/18881/kubeconfig') + self.assertEqual(m.call_url, "/lke/clusters/18881/kubeconfig") def test_node_view(self): """ @@ -66,9 +73,9 @@ def test_node_view(self): """ cluster = LKECluster(self.client, 18881) - with self.mock_get('/lke/clusters/18881/nodes/123456') as m: + with self.mock_get("/lke/clusters/18881/nodes/123456") as m: node = cluster.node_view(123456) - self.assertEqual(m.call_url, '/lke/clusters/18881/nodes/123456') + self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/123456") self.assertIsNotNone(node) self.assertEqual(node.id, "123456") self.assertEqual(node.instance_id, 123458) @@ -82,7 +89,7 @@ def test_node_delete(self): with self.mock_delete() as m: cluster.node_delete(1234) - self.assertEqual(m.call_url, '/lke/clusters/18881/nodes/1234') + self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/1234") def test_node_recycle(self): """ @@ -92,7 +99,9 @@ def test_node_recycle(self): with self.mock_post({}) as m: cluster.node_recycle(1234) - self.assertEqual(m.call_url, '/lke/clusters/18881/nodes/1234/recycle') + self.assertEqual( + m.call_url, "/lke/clusters/18881/nodes/1234/recycle" + ) def test_cluster_nodes_recycle(self): """ @@ -102,7 +111,7 @@ def test_cluster_nodes_recycle(self): with self.mock_post({}) as m: cluster.cluster_nodes_recycle() - self.assertEqual(m.call_url, '/lke/clusters/18881/recycle') + self.assertEqual(m.call_url, "/lke/clusters/18881/recycle") def test_cluster_regenerate(self): """ @@ -112,7 +121,7 @@ def test_cluster_regenerate(self): with self.mock_post({}) as m: cluster.cluster_regenerate() - self.assertEqual(m.call_url, '/lke/clusters/18881/regenerate') + self.assertEqual(m.call_url, "/lke/clusters/18881/regenerate") def test_service_token_delete(self): """ @@ -122,8 +131,4 @@ def test_service_token_delete(self): with self.mock_delete() as m: cluster.service_token_delete() - self.assertEqual(m.call_url, '/lke/clusters/18881/servicetoken') - - - - \ No newline at end of file + self.assertEqual(m.call_url, "/lke/clusters/18881/servicetoken") diff --git a/test/objects/longview_test.py b/test/objects/longview_test.py index e6134f1b5..da28c5a4b 100644 --- a/test/objects/longview_test.py +++ b/test/objects/longview_test.py @@ -9,6 +9,7 @@ class LongviewClientTest(ClientBaseCase): """ Tests methods of the LongviewClient class """ + def test_get_client(self): """ Tests that a client is loaded correctly by ID @@ -16,7 +17,7 @@ def test_get_client(self): client = LongviewClient(self.client, 1234) self.assertEqual(client._populated, False) - self.assertEqual(client.label, 'test_client_1') + self.assertEqual(client.label, "test_client_1") self.assertEqual(client._populated, True) self.assertIsInstance(client.created, datetime) @@ -27,22 +28,22 @@ def test_get_client(self): self.assertFalse(client.apps.mysql) self.assertFalse(client.apps.apache) - self.assertEqual(client.install_code, '12345678-ABCD-EF01-23456789ABCDEF12') - self.assertEqual(client.api_key, '12345678-ABCD-EF01-23456789ABCDEF12') + self.assertEqual( + client.install_code, "12345678-ABCD-EF01-23456789ABCDEF12" + ) + self.assertEqual(client.api_key, "12345678-ABCD-EF01-23456789ABCDEF12") def test_update_label(self): """ Tests that updating a client's label contacts the api correctly. """ - with self.mock_put('longview/clients/1234') as m: + with self.mock_put("longview/clients/1234") as m: client = LongviewClient(self.client, 1234) client.label = "updated" client.save() - self.assertEqual(m.call_url, '/longview/clients/1234') - self.assertEqual(m.call_data, { - "label": "updated" - }) + self.assertEqual(m.call_url, "/longview/clients/1234") + self.assertEqual(m.call_data, {"label": "updated"}) def test_delete_client(self): """ @@ -52,13 +53,14 @@ def test_delete_client(self): client = LongviewClient(self.client, 1234) client.delete() - self.assertEqual(m.call_url, '/longview/clients/1234') + self.assertEqual(m.call_url, "/longview/clients/1234") class LongviewSubscriptionTest(ClientBaseCase): """ Tests methods of the LongviewSubscription class """ + def test_get_subscription(self): """ Tests that a subscription is loaded correctly by ID @@ -66,11 +68,11 @@ def test_get_subscription(self): sub = LongviewSubscription(self.client, "longview-40") self.assertEqual(sub._populated, False) - self.assertEqual(sub.label, 'Longview Pro 40 pack') + self.assertEqual(sub.label, "Longview Pro 40 pack") self.assertEqual(sub._populated, True) self.assertEqual(sub.clients_included, 40) self.assertIsInstance(sub.price, MappedObject) - self.assertEqual(sub.price.hourly, .15) + self.assertEqual(sub.price.hourly, 0.15) self.assertEqual(sub.price.monthly, 100) diff --git a/test/objects/nodebalancers_test.py b/test/objects/nodebalancers_test.py index e78934a71..4450cdcdc 100644 --- a/test/objects/nodebalancers_test.py +++ b/test/objects/nodebalancers_test.py @@ -1,4 +1,3 @@ -from datetime import datetime from test.base import ClientBaseCase from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode @@ -9,6 +8,7 @@ class NodeBalancerConfigTest(ClientBaseCase): """ Tests methods of the NodeBalancerConfig class """ + def test_get_config(self): """ Tests that a config is loaded correctly by ID @@ -44,6 +44,7 @@ class NodeBalancerNodeTest(ClientBaseCase): """ Tests methods of the NodeBalancerNode class """ + def test_get_node(self): """ Tests that a node is loaded correctly by ID @@ -66,26 +67,36 @@ def test_create_node(self): """ Tests that a node can be created """ - with self.mock_post('nodebalancers/123456/configs/65432/nodes/54321') as m: + with self.mock_post( + "nodebalancers/123456/configs/65432/nodes/54321" + ) as m: config = NodeBalancerConfig(self.client, 65432, 123456) - node = config.node_create('node54321', '192.168.210.120', - weight=50, mode='accept') + node = config.node_create( + "node54321", "192.168.210.120", weight=50, mode="accept" + ) self.assertIsNotNone(node) self.assertEqual(node.id, 54321) - self.assertEqual(m.call_url, '/nodebalancers/123456/configs/65432/nodes') - self.assertEqual(m.call_data, { - "label": "node54321", - "address": "192.168.210.120", - "weight": 50, - "mode": "accept" - }) + self.assertEqual( + m.call_url, "/nodebalancers/123456/configs/65432/nodes" + ) + self.assertEqual( + m.call_data, + { + "label": "node54321", + "address": "192.168.210.120", + "weight": 50, + "mode": "accept", + }, + ) def test_update_node(self): """ Tests that a node can be updated """ - with self.mock_put('nodebalancers/123456/configs/65432/nodes/54321') as m: + with self.mock_put( + "nodebalancers/123456/configs/65432/nodes/54321" + ) as m: node = self.client.load(NodeBalancerNode, 54321, (65432, 123456)) node.label = "ThisNewLabel" node.weight = 60 @@ -93,13 +104,18 @@ def test_update_node(self): node.address = "192.168.210.121" node.save() - self.assertEqual(m.call_url, '/nodebalancers/123456/configs/65432/nodes/54321') - self.assertEqual(m.call_data, { - "label": "ThisNewLabel", - "address": "192.168.210.121", - "mode": "drain", - "weight": 60 - }) + self.assertEqual( + m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321" + ) + self.assertEqual( + m.call_data, + { + "label": "ThisNewLabel", + "address": "192.168.210.121", + "mode": "drain", + "weight": 60, + }, + ) def test_delete_node(self): """ @@ -109,4 +125,6 @@ def test_delete_node(self): node = NodeBalancerNode(self.client, 54321, (65432, 123456)) node.delete() - self.assertEqual(m.call_url, '/nodebalancers/123456/configs/65432/nodes/54321') + self.assertEqual( + m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321" + ) diff --git a/test/objects/profile_test.py b/test/objects/profile_test.py index 3a57b6757..2c72d9263 100644 --- a/test/objects/profile_test.py +++ b/test/objects/profile_test.py @@ -1,5 +1,4 @@ from datetime import datetime - from test.base import ClientBaseCase from linode_api4.objects import SSHKey @@ -9,6 +8,7 @@ class SSHKeyTest(ClientBaseCase): """ Tests methods of the SSHKey class """ + def test_get_ssh_key(self): """ Tests that an SSHKey is loaded correctly by ID @@ -16,27 +16,31 @@ def test_get_ssh_key(self): key = SSHKey(self.client, 22) self.assertEqual(key._populated, False) - self.assertEqual(key.label, 'Home Ubuntu PC') + self.assertEqual(key.label, "Home Ubuntu PC") self.assertEqual(key._populated, True) - self.assertEqual(key.created, datetime(year=2018, month=9, day=14, hour=13, - minute=0, second=0)) + self.assertEqual( + key.created, + datetime(year=2018, month=9, day=14, hour=13, minute=0, second=0), + ) self.assertEqual(key.id, 22) self.assertEqual( - key.ssh_key, "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" - "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" - "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" - "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" - "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" - "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" - "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" - "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" - "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" - "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" - "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" - "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" - "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" - "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command") + key.ssh_key, + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDe9NlKepJsI/S98" + "ISBJmG+cpEARtM0T1Qa5uTOUB/vQFlHmfQW07ZfA++ybPses0vRCD" + "eWyYPIuXcV5yFrf8YAW/Am0+/60MivT3jFY0tDfcrlvjdJAf1NpWO" + "TVlzv0gpsHFO+XIZcfEj3V0K5+pOMw9QGVf6Qbg8qzHVDPFdYKu3i" + "muc9KHY8F/b4DN/Wh17k3xAJpspCZEFkn0bdaYafJj0tPs0k78JRo" + "F2buc3e3M6dlvHaoON1votmrri9lut65OIpglOgPwE3QU8toGyyoC" + "MGaT4R7kIRjXy3WSyTMAi0KTAdxRK+IlDVMXWoE5TdLovd0a9L7qy" + "nZungKhKZUgFma7r9aTFVHXKh29Tzb42neDTpQnZ/Et735sDC1vfz" + "/YfgZNdgMUXFJ3+uA4M/36/Vy3Dpj2Larq3qY47RDFitmwSzwUlfz" + "tUoyiQ7e1WvXHT4N4Z8K2FPlTvNMg5CSjXHdlzcfiRFPwPn13w36v" + "TvAUxPvTa84P1eOLDp/JzykFbhHNh8Cb02yrU28zDeoTTyjwQs0eH" + "d1wtgIXJ8wuUgcaE4LgcgLYWwiKTq4/FnX/9lfvuAiPFl6KLnh23b" + "cKwnNA7YCWlb1NNLb2y+mCe91D8r88FGvbnhnOuVjd/SxQWDHtxCI" + "CmhW7erNJNVxYjtzseGpBLmRRUTsT038w== dorthu@dorthu-command", + ) def test_update_ssh_key(self): """ @@ -44,15 +48,13 @@ def test_update_ssh_key(self): """ key = SSHKey(self.client, 22) - key.label = 'New Label' + key.label = "New Label" - with self.mock_put('profile/sshkeys/22') as m: + with self.mock_put("profile/sshkeys/22") as m: key.save() - self.assertEqual(m.call_url, '/profile/sshkeys/22') - self.assertEqual(m.call_data, { - "label": 'New Label' - }) + self.assertEqual(m.call_url, "/profile/sshkeys/22") + self.assertEqual(m.call_data, {"label": "New Label"}) def test_delete_ssh_key(self): """ @@ -63,4 +65,4 @@ def test_delete_ssh_key(self): with self.mock_delete() as m: key.delete() - self.assertEqual(m.call_url, '/profile/sshkeys/22') + self.assertEqual(m.call_url, "/profile/sshkeys/22") diff --git a/test/objects/tag_test.py b/test/objects/tag_test.py index ba2d726c2..a6c78efbb 100644 --- a/test/objects/tag_test.py +++ b/test/objects/tag_test.py @@ -1,4 +1,3 @@ -from datetime import datetime from test.base import ClientBaseCase from linode_api4.objects import Instance, Tag @@ -8,39 +7,40 @@ class TagTest(ClientBaseCase): """ Tests methods of the Tag class """ + def test_get_tag(self): """ Tests that Tag is loaded correctly by label """ - tag = Tag(self.client, 'something') + tag = Tag(self.client, "something") self.assertEqual(tag.label, "something") - self.assertFalse(hasattr(tag, '_raw_objects')) + self.assertFalse(hasattr(tag, "_raw_objects")) def test_load_tag(self): """ Tests that the LinodeClient can load a tag """ - tag = self.client.load(Tag, 'something') + tag = self.client.load(Tag, "something") - self.assertEqual(tag.label, 'something') - self.assertTrue(hasattr(tag, '_raw_objects')) # got the raw objects + self.assertEqual(tag.label, "something") + self.assertTrue(hasattr(tag, "_raw_objects")) # got the raw objects print(tag._raw_objects) # objects loaded up right self.assertEqual(len(tag.objects), 1) self.assertEqual(tag.objects[0].id, 123) - self.assertEqual(tag.objects[0].label, 'linode123') - self.assertEqual(tag.objects[0].tags, ['something']) + self.assertEqual(tag.objects[0].label, "linode123") + self.assertEqual(tag.objects[0].tags, ["something"]) def test_delete_tag(self): """ Tests that you can delete a tag """ with self.mock_delete() as m: - tag = Tag(self.client, 'nothing') + tag = Tag(self.client, "nothing") result = tag.delete() self.assertEqual(result, True) - self.assertEqual(m.call_url, '/tags/nothing') + self.assertEqual(m.call_url, "/tags/nothing") diff --git a/test/objects/volume_test.py b/test/objects/volume_test.py index 3e967caf0..1dd652eb4 100644 --- a/test/objects/volume_test.py +++ b/test/objects/volume_test.py @@ -16,19 +16,19 @@ def test_get_volume(self): volume = Volume(self.client, 1) self.assertEqual(volume._populated, False) - self.assertEqual(volume.label, 'block1') + self.assertEqual(volume.label, "block1") self.assertEqual(volume._populated, True) self.assertEqual(volume.size, 40) self.assertEqual(volume.linode, None) - self.assertEqual(volume.status, 'active') + self.assertEqual(volume.status, "active") self.assertIsInstance(volume.updated, datetime) - self.assertEqual(volume.region.id, 'us-east-1a') + self.assertEqual(volume.region.id, "us-east-1a") assert volume.tags == ["something"] - self.assertEqual(volume.filesystem_path, 'this/is/a/file/path') - self.assertEqual(volume.hardware_type, 'hdd') + self.assertEqual(volume.filesystem_path, "this/is/a/file/path") + self.assertEqual(volume.hardware_type, "hdd") self.assertEqual(volume.linode_label, None) def test_update_volume_tags(self): @@ -37,12 +37,12 @@ def test_update_volume_tags(self): """ volume = self.client.volumes().first() - with self.mock_put('volumes/1') as m: - volume.tags = ['test1', 'test2'] + with self.mock_put("volumes/1") as m: + volume.tags = ["test1", "test2"] volume.save() - assert m.call_url == '/volumes/{}'.format(volume.id) - assert m.call_data['tags'] == ['test1', 'test2'] + assert m.call_url == "/volumes/{}".format(volume.id) + assert m.call_data["tags"] == ["test1", "test2"] def test_clone_volume(self): """ @@ -51,10 +51,14 @@ def test_clone_volume(self): """ volume_to_clone = self.client.volumes().first() - with self.mock_post(f'volumes/{volume_to_clone.id}') as mock: - new_volume = volume_to_clone.clone('new-volume') - assert mock.call_url == f'/volumes/{volume_to_clone.id}/clone' - self.assertEqual(str(new_volume.region), str(volume_to_clone.region), 'the regions should be the same') + with self.mock_post(f"volumes/{volume_to_clone.id}") as mock: + new_volume = volume_to_clone.clone("new-volume") + assert mock.call_url == f"/volumes/{volume_to_clone.id}/clone" + self.assertEqual( + str(new_volume.region), + str(volume_to_clone.region), + "the regions should be the same", + ) assert new_volume.id != str(volume_to_clone.id) def test_resize_volume(self): @@ -63,10 +67,10 @@ def test_resize_volume(self): """ volume = self.client.volumes().first() - with self.mock_post(f'volumes/{volume.id}') as mock: + with self.mock_post(f"volumes/{volume.id}") as mock: volume.resize(3048) - assert mock.call_url == f'/volumes/{volume.id}/resize' - assert str(mock.call_data['size']) == '3048' + assert mock.call_url == f"/volumes/{volume.id}/resize" + assert str(mock.call_data["size"]) == "3048" def test_detach_volume(self): """ @@ -74,9 +78,9 @@ def test_detach_volume(self): """ volume = self.client.volumes()[2] - with self.mock_post(f'volumes/{volume.id}') as mock: + with self.mock_post(f"volumes/{volume.id}") as mock: result = volume.detach() - assert mock.call_url == f'/volumes/{volume.id}/detach' + assert mock.call_url == f"/volumes/{volume.id}/detach" assert result is True def test_attach_volume_to_linode(self): @@ -85,8 +89,8 @@ def test_attach_volume_to_linode(self): """ volume = self.client.volumes().first() - with self.mock_post(f'volumes/{volume.id}') as mock: + with self.mock_post(f"volumes/{volume.id}") as mock: result = volume.attach(1) - assert mock.call_url == f'/volumes/{volume.id}/attach' + assert mock.call_url == f"/volumes/{volume.id}/attach" assert result is True - assert str(mock.call_data['linode_id']) == '1' \ No newline at end of file + assert str(mock.call_data["linode_id"]) == "1" diff --git a/test/paginated_list_test.py b/test/paginated_list_test.py index 77643a5fd..2d6705561 100644 --- a/test/paginated_list_test.py +++ b/test/paginated_list_test.py @@ -10,28 +10,31 @@ def setUp(self): Creates sample mocked lists for use in the test cases """ self.normal_list = list(range(25)) - self.paginated_list = PaginatedList(None, None, page=self.normal_list, - total_items=25) + self.paginated_list = PaginatedList( + None, None, page=self.normal_list, total_items=25 + ) def test_slice_normal(self): """ Tests that bounded, forward slices work as expected """ - slices = ( (1, 10), (10, 20), (5, 25), (0, 10) ) + slices = ((1, 10), (10, 20), (5, 25), (0, 10)) - for (start, stop) in slices: - self.assertEqual(self.normal_list[start:stop], - self.paginated_list[start:stop]) + for start, stop in slices: + self.assertEqual( + self.normal_list[start:stop], self.paginated_list[start:stop] + ) def test_slice_negative(self): """ Tests that negative indexing works in slices """ - slices = ( (-10,-5), (-20, 20), (3, -10) ) + slices = ((-10, -5), (-20, 20), (3, -10)) - for (start, stop) in slices: - self.assertEqual(self.normal_list[start:stop], - self.paginated_list[start:stop]) + for start, stop in slices: + self.assertEqual( + self.normal_list[start:stop], self.paginated_list[start:stop] + ) def test_slice_no_lower_bound(self): """ @@ -68,7 +71,7 @@ def test_slice_unsupported_step(self): """ Tests that steps outside of 1 raise a NotImplementedError """ - for step in ( -1, 0, 2, 3 ): + for step in (-1, 0, 2, 3): with self.assertRaises(NotImplementedError): self.paginated_list[::step] @@ -79,11 +82,12 @@ def test_slice_backward_indexing(self): self.assertEqual(self.normal_list[10:5], self.paginated_list[10:5]) -class TestModel(): +class TestModel: """ This is a test model class used to simulate an actual model that would be returned by the API """ + @classmethod def make_instance(*args, **kwargs): return TestModel() @@ -97,7 +101,7 @@ def test_page_size_in_request(self): for i in (25, 100, 500): # these are the pages we're sending in to the mocked list - first_page = [ TestModel() for x in range(i) ] + first_page = [TestModel() for x in range(i)] second_page = { "data": [{"id": 1}], "pages": 2, @@ -110,11 +114,15 @@ def test_page_size_in_request(self): client.get = MagicMock(return_value=second_page) # let's do it! - p = PaginatedList(client, "/test", page=first_page, max_pages=2, total_items=i+1) - p[i] # load second page + p = PaginatedList( + client, "/test", page=first_page, max_pages=2, total_items=i + 1 + ) + p[i] # load second page # and we called the next page URL with the correct page_size - assert client.get.call_args == call("//test?page=2&page_size={}".format(i), filters=None) + assert client.get.call_args == call( + "//test?page=2&page_size={}".format(i), filters=None + ) def test_no_pages(self): """ @@ -125,4 +133,4 @@ def test_no_pages(self): p = PaginatedList(client, "/test", page=[], max_pages=0, total_items=0) - assert(len(p) == 0) + assert len(p) == 0 diff --git a/test/util_test.py b/test/util_test.py index cdee919ab..3123a4447 100644 --- a/test/util_test.py +++ b/test/util_test.py @@ -18,16 +18,10 @@ def test_drop_null_keys_nonrecursive(self): "cool": { "test": "bar", "cool": None, - } + }, } - expected_output = { - "foo": "bar", - "cool": { - "test": "bar", - "cool": None - } - } + expected_output = {"foo": "bar", "cool": {"test": "bar", "cool": None}} assert drop_null_keys(value, recursive=False) == expected_output @@ -42,13 +36,8 @@ def test_drop_null_keys_recursive(self): "cool": { "test": "bar", "cool": None, - "list": [ - { - "foo": "bar", - "test": None - } - ] - } + "list": [{"foo": "bar", "test": None}], + }, } expected_output = { @@ -59,8 +48,8 @@ def test_drop_null_keys_recursive(self): { "foo": "bar", } - ] - } + ], + }, } assert drop_null_keys(value) == expected_output From ec0bfcf7612ba30da24a8288319f71faf620e510 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 12 Apr 2023 15:21:56 -0400 Subject: [PATCH 066/379] new: Add GHA release automation workflow (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds a `publish-pypi` GitHub Actions workflow for automating releases to PyPI on GitHub release. Additionally, this change alters the setup.py `version` field to dynamically resolve the package version from either the `LINODE_SDK_VERSION` environment variable or the embedded `baked_version` file (required for source distributions). This change allows us to dynamically version the package at build-time, which is a requirement for the GHA release automation. The `PYPI_API_TOKEN` secret has been added to this repository in preparation for this change. --- .github/workflows/publish-pypi.yaml | 38 +++++++++++++++++++++++++ .gitignore | 1 + MANIFEST.in | 1 + Makefile | 18 ++++++++++-- setup.py | 43 +++++++++++++++++++++++++++-- 5 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/publish-pypi.yaml diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml new file mode 100644 index 000000000..0d0dff60b --- /dev/null +++ b/.github/workflows/publish-pypi.yaml @@ -0,0 +1,38 @@ +name: release +on: + workflow_dispatch: null + release: + types: [ published ] +jobs: + pypi-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Update system packages + run: sudo apt-get update -y + + - name: Install make + run: sudo apt-get install -y build-essential + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install wheel + + - name: Install package requirements + run: make requirements + + - name: Build the package + run: make build + env: + LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} + + - name: Publish the release artifacts to PyPI + uses: pypa/gh-action-pypi-publish@37f50c210e3d2f9450da2cd423303d6a14a6e29f # pin@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5007e7880..57450459a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ docs/_build/* .pytest_cache/* .tox/* venv +baked_version \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index af3d266ab..96c48f6d8 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ graft test global-exclude *.pyc +include baked_version \ No newline at end of file diff --git a/Makefile b/Makefile index b64c31449..e51e574bc 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,8 @@ PYTHON ?= python3 @PHONEY: clean clean: mkdir -p dist - rm dist/* + rm -r dist + rm -f baked_version @PHONEY: build build: clean @@ -15,19 +16,32 @@ build: clean release: build twine upload dist/* + +install: clean + python3 setup.py install + + +requirements: + pip install -r requirements.txt -r requirements-dev.txt + + black: black linode_api4 test + isort: isort linode_api4 test + autoflake: autoflake linode_api4 test + format: black isort autoflake + lint: isort --check-only linode_api4 test autoflake --check linode_api4 test black --check --verbose linode_api4 test - pylint linode_api4 \ No newline at end of file + pylint linode_api4 diff --git a/setup.py b/setup.py index 474ca9e0e..44a78c465 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ Based on a template here: https://github.com/pypa/sampleproject/blob/master/setup.py """ - +import os # Always prefer setuptools over distutils import sys # To use a consistent encoding @@ -17,21 +17,58 @@ here = path.abspath(path.dirname(__file__)) + def get_test_suite(): test_loader = TestLoader() return test_loader.discover('test', pattern='*_test.py') + +def get_baked_version(): + """ + Attempts to read the version from the baked_version file + """ + with open("./baked_version", "r", encoding="utf-8") as f: + result = f.read() + + return result + + +def bake_version(v): + """ + Writes the given version to the baked_version file + """ + with open("./baked_version", "w", encoding="utf-8") as f: + f.write(v) + + # Get the long description from the README file with open(path.join(here, 'README.rst'), encoding='utf-8') as f: long_description = f.read() +# If there's already a baked version, use it rather than attempting +# to resolve the version from env. +# This is useful for installing from an SDist where the version +# cannot be dynamically resolved. +# +# NOTE: baked_version is deleted when running `make build` and `make install`, +# so it should always be recreated during the build process. +if path.isfile("baked_version"): + version = get_baked_version() +else: + # Otherwise, retrieve and bake the version as normal + version = os.getenv("LINODE_SDK_VERSION", "0.0.0") + bake_version(version) + +if version.startswith("v"): + version = version[1:] + setup( name='linode_api4', # Versions should comply with PEP440. For a discussion on single-sourcing # the version across setup.py and the project code, see # https://packaging.python.org/en/latest/single_source_version.html - version='5.3.0', + version=version, description='The official python SDK for Linode API v4', long_description=long_description, @@ -89,5 +126,5 @@ def get_test_suite(): extras_require={ "test": ["tox"], }, - test_suite = 'setup.get_test_suite' + test_suite='setup.get_test_suite' ) From 26987bf1fe1937a11e024ef9869b2b1654cdaabd Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 12 Apr 2023 15:26:17 -0400 Subject: [PATCH 067/379] Fix: Domain update issue when null properties are saved (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Fixed issue when attempting to update Domain with some null values. ## ✔️ How to Test Run `tox`. Ticket: TPT-1871 Resolves #208 --- linode_api4/objects/base.py | 2 +- test/objects/domain_test.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 test/objects/domain_test.py diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index aa558a676..a1cf74977 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -256,7 +256,7 @@ def _serialize(self): elif isinstance(v, MappedObject): result[k] = v.dict - return result + return {k: v for k, v in result.items() if v} def _api_get(self): """ diff --git a/test/objects/domain_test.py b/test/objects/domain_test.py new file mode 100644 index 000000000..5fc0d5ea2 --- /dev/null +++ b/test/objects/domain_test.py @@ -0,0 +1,17 @@ +from linode_api4.objects import Domain +from test.base import ClientBaseCase + +class DomainGeneralTest(ClientBaseCase): + """ + Tests methods of the Domain class. + """ + + def test_save_null_values_excluded(self): + with self.mock_put('domains/12345') as m: + domain = self.client.load(Domain, 12345) + + domain.type = "slave" + domain.master_ips = ["127.0.0.1"] + domain.save() + + self.assertTrue('group' not in m.call_data.keys()) \ No newline at end of file From 366a974d376d1c41587b9f7e04b31b62749b6ddb Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 14 Apr 2023 13:55:39 -0400 Subject: [PATCH 068/379] fix: Update build badge to reflect dev branch status (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change updates the build badge in `README.rst` to reflect the status of the `dev` branch. --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 89e5a6ef2..3cd7acacb 100644 --- a/README.rst +++ b/README.rst @@ -5,8 +5,8 @@ The official python library for the `Linode API v4`_ in python. .. _Linode API v4: https://developers.linode.com/api/v4/ -.. image:: https://travis-ci.com/linode/linode_api4-python.svg?branch=master - :target: https://travis-ci.com/linode/linode_api4-python +.. image:: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests + :target: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests .. image:: https://badge.fury.io/py/linode-api4.svg :target: https://badge.fury.io/py/linode-api4 From 53ddf8559e449c80c5b00cb51d9718980051a141 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 14 Apr 2023 13:57:06 -0400 Subject: [PATCH 069/379] Fix lint (#243) --- test/objects/domain_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/objects/domain_test.py b/test/objects/domain_test.py index 5fc0d5ea2..ded8321db 100644 --- a/test/objects/domain_test.py +++ b/test/objects/domain_test.py @@ -1,17 +1,19 @@ -from linode_api4.objects import Domain from test.base import ClientBaseCase +from linode_api4.objects import Domain + + class DomainGeneralTest(ClientBaseCase): """ Tests methods of the Domain class. """ def test_save_null_values_excluded(self): - with self.mock_put('domains/12345') as m: + with self.mock_put("domains/12345") as m: domain = self.client.load(Domain, 12345) domain.type = "slave" domain.master_ips = ["127.0.0.1"] domain.save() - self.assertTrue('group' not in m.call_data.keys()) \ No newline at end of file + self.assertTrue("group" not in m.call_data.keys()) From b2bb9b7cab895459430a335952a1d993a3076486 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 17 Apr 2023 11:14:51 -0400 Subject: [PATCH 070/379] doc: Add documentation for all groups and endpoint methods in `linode_client.py` (#241) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds and updates documentation strings for all global groups and endpoint methods. --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- linode_api4/linode_client.py | 347 ++++++++++++++++++++++++++++++++--- 1 file changed, 319 insertions(+), 28 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 208f1d1d9..decf9c448 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -48,6 +48,8 @@ def types(self, *filters): standard_types = client.linode.types(Type.class == "standard") + API documentation: https://www.linode.com/docs/api/linode-types/#types-list + :param filters: Any number of filters to apply to the query. :returns: A list of types that match the query. @@ -62,6 +64,8 @@ def instances(self, *filters): prod_linodes = client.linode.instances(Instance.group == "prod") + API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list + :param filters: Any number of filters to apply to this query. :returns: A list of Instances that matched the query. @@ -78,6 +82,8 @@ def stackscripts(self, *filters, **kwargs): my_stackscripts = client.linode.stackscripts(mine_only=True) + API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list + :param filters: Any number of filters to apply to this query. :param mine_only: If True, returns only private StackScripts :type mine_only: bool @@ -111,6 +117,8 @@ def kernels(self, *filters): Returns a list of available :any:`Kernels`. Kernels are used when creating or updating :any:`LinodeConfigs,LinodeConfig>`. + API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list + :param filters: Any number of filters to apply to this query. :returns: A list of available kernels that match the query. @@ -197,6 +205,8 @@ def instance_create( successfully until disks and configs are created, or it is otherwise configured. + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-create + :param ltype: The Instance Type we are creating :type ltype: str or Type :param region: The Region in which we are creating the Instance @@ -298,6 +308,8 @@ def stackscript_create( """ Creates a new :any:`StackScript` on your account. + API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-create + :param label: The label for this StackScript. :type label: str :param script: The script to run when an :any:`Instance` is deployed with @@ -375,6 +387,8 @@ def __call__(self): profile = client.profile() + API Documentation: https://www.linode.com/docs/api/profile/#profile-view + :returns: The acting user's profile. :rtype: Profile """ @@ -390,13 +404,32 @@ def __call__(self): def tokens(self, *filters): """ - Returns the Person Access Tokens active for this user + Returns the Person Access Tokens active for this user. + + API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of tokens that matches the query. + :rtype: PaginatedList of PersonalAccessToken """ return self.client._get_and_filter(PersonalAccessToken, *filters) def token_create(self, label=None, expiry=None, scopes=None, **kwargs): """ - Creates and returns a new Personal Access Token + Creates and returns a new Personal Access Token. + + API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-create + + :param label: The label of the new Personal Access Token. + :type label: str + :param expiry: When the new Personal Accses Token will expire. + :type expiry: datetime or str + :param scopes: A space-separated list of OAuth scopes for this token. + :type scopes: str + + :returns: The new Personal Access Token. + :rtype: PersonalAccessToken """ if label: kwargs["label"] = label @@ -421,12 +454,26 @@ def token_create(self, label=None, expiry=None, scopes=None, **kwargs): def apps(self, *filters): """ Returns the Authorized Applications for this user + + API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Authorized Applications for this user + :rtype: PaginatedList of AuthorizedApp """ return self.client._get_and_filter(AuthorizedApp, *filters) def ssh_keys(self, *filters): """ - Returns the SSH Public Keys uploaded to your profile + Returns the SSH Public Keys uploaded to your profile. + + API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of SSH Keys for this profile. + :rtype: PaginatedList of SSHKey """ return self.client._get_and_filter(SSHKey, *filters) @@ -435,6 +482,8 @@ def ssh_key_upload(self, key, label): Uploads a new SSH Public Key to your profile This key can be used in later Linode deployments. + API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-add + :param key: The ssh key, or a path to the ssh key. If a path is provided, the file at the path must exist and be readable or an exception will be thrown. @@ -489,6 +538,8 @@ def versions(self, *filters): Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be used when creating an LKE Cluster. + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list + :param filters: Any number of filters to apply to the query. :returns: A Paginated List of kube versions that match the query. @@ -501,6 +552,8 @@ def clusters(self, *filters): Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong to this account. + https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list + :param filters: Any number of filters to apply to the query. :returns: A Paginated List of LKE clusters that match the query. @@ -529,6 +582,8 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): kube_version ) + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-create + :param region: The Region to create this LKE Cluster in. :type region: Region or str :param label: The label for the new LKE Cluster. @@ -600,10 +655,21 @@ def node_pool(self, node_type, node_count): class LongviewGroup(Group): + """ + Collections related to Linode Longview. + """ + def clients(self, *filters): """ Requests and returns a paginated list of LongviewClients on your account. + + API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Longview Clients matching the given filters. + :rtype: PaginatedList of LongviewClient """ return self.client._get_and_filter(LongviewClient, *filters) @@ -611,6 +677,8 @@ def client_create(self, label=None): """ Creates a new LongviewClient, optionally with a given label. + API Documentation: https://www.linode.com/docs/api/longview/#longview-client-create + :param label: The label for the new client. If None, a default label based on the new client's ID will be used. @@ -634,11 +702,22 @@ def client_create(self, label=None): def subscriptions(self, *filters): """ Requests and returns a paginated list of LongviewSubscriptions available + + API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Longview Subscriptions matching the given filters. + :rtype: PaginatedList of LongviewSubscription """ return self.client._get_and_filter(LongviewSubscription, *filters) class AccountGroup(Group): + """ + Collections related to your account. + """ + def __call__(self): """ Retrieves information about the acting user's account, such as billing @@ -647,6 +726,8 @@ def __call__(self): account = client.account() + API Documentation: https://www.linode.com/docs/api/account/#account-view + :returns: Returns the acting user's account information. :rtype: Account """ @@ -660,12 +741,28 @@ def __call__(self): return Account(self.client, result["email"], result) def events(self, *filters): + """ + Lists events on the current account matching the given filters. + + API Documentation: https://www.linode.com/docs/api/account/#events-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of events on the current account matching the given filters. + :rtype: PaginatedList of Event + """ + return self.client._get_and_filter(Event, *filters) def events_mark_seen(self, event): """ Marks event as the last event we have seen. If event is an int, it is treated as an event_id, otherwise it should be an event object whose id will be used. + + API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen + + :param event: The Linode event to mark as seen. + :type event: Event or int """ last_seen = event if isinstance(event, int) else event.id self.client.post( @@ -675,8 +772,13 @@ def events_mark_seen(self, event): def settings(self): """ - Resturns the account settings data for this acocunt. This is not a + Returns the account settings data for this acocunt. This is not a listing endpoint. + + API Documentation: https://www.linode.com/docs/api/account/#account-settings-view + + :returns: The account settings data for this account. + :rtype: AccountSettings """ result = self.client.get("/account/settings") @@ -691,25 +793,54 @@ def settings(self): def invoices(self): """ - Returns Invoices issued to this account + Returns Invoices issued to this account. + + API Documentation: https://www.linode.com/docs/api/account/#invoices-list + + :param filters: Any number of filters to apply to this query. + + :returns: Invoices issued to this account. + :rtype: PaginatedList of Invoice """ return self.client._get_and_filter(Invoice) def payments(self): """ - Returns a list of Payments made to this account + Returns a list of Payments made on this account. + + API Documentation: https://www.linode.com/docs/api/account/#payments-list + + :returns: A list of payments made on this account. + :rtype: PaginatedList of Payment """ return self.client._get_and_filter(Payment) def oauth_clients(self, *filters): """ - Returns the OAuth Clients associated to this account + Returns the OAuth Clients associated with this account. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of OAuth Clients associated with this account. + :rtype: PaginatedList of OAuthClient """ return self.client._get_and_filter(OAuthClient, *filters) def oauth_client_create(self, name, redirect_uri, **kwargs): """ - Make a new OAuth Client and return it + Creates a new OAuth client. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-client-create + + :param name: The name of this application. + :type name: str + :param redirect_uri: The location a successful log in from https://login.linode.com should be redirected to for this client. + :type redirect_uri: str + + :returns: The created OAuth Client. + :rtype: OAuthClient """ params = { "label": name, @@ -729,13 +860,25 @@ def oauth_client_create(self, name, redirect_uri, **kwargs): def users(self, *filters): """ - Returns a list of users on this account + Returns a list of users on this account. + + API Documentation: https://www.linode.com/docs/api/account/#users-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of users on this account. + :rtype: PaginatedList of User """ return self.client._get_and_filter(User, *filters) def transfer(self): """ - Returns a MappedObject containing the account's transfer pool data + Returns a MappedObject containing the account's transfer pool data. + + API Documentation: https://www.linode.com/docs/api/account/#network-utilization-view + + :returns: Information about this account's transfer pool data. + :rtype: MappedObject """ result = self.client.get("/account/transfer") @@ -757,6 +900,8 @@ def user_create(self, email, username, restricted=True): The new user will receive an email inviting them to set up their password. This must be completed before they can log in. + API Documentation: https://www.linode.com/docs/api/account/#user-create + :param email: The new user's email address. This is used to finish setting up their user account. :type email: str @@ -791,12 +936,16 @@ def user_create(self, email, username, restricted=True): class NetworkingGroup(Group): + """ + Collections related to Linode Networking. + """ + def firewalls(self, *filters): """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - Retrieves the Firewalls your user has access to. + API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list + :param filters: Any number of filters to apply to this query. :returns: A list of Firewalls the acting user can access. @@ -806,11 +955,11 @@ def firewalls(self, *filters): def firewall_create(self, label, rules, **kwargs): """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - Creates a new Firewall, either in the given Region or attached to the given Instance. + API Documentation: https://www.linode.com/docs/api/networking/#firewall-create + :param label: The label for the new Firewall. :type label: str :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. @@ -866,20 +1015,56 @@ def firewall_create(self, label, rules, **kwargs): return f def ips(self, *filters): + """ + Returns a list of IP addresses on this account, excluding private addresses. + + API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of IP addresses on this account. + :rtype: PaginatedList of IPAddress + """ return self.client._get_and_filter(IPAddress, *filters) def ipv6_ranges(self, *filters): + """ + Returns a list of IPv6 ranges on this account. + + API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of IPv6 ranges on this account. + :rtype: PaginatedList of IPv6Range + """ return self.client._get_and_filter(IPv6Range, *filters) def ipv6_pools(self, *filters): + """ + Returns a list of IPv6 pools on this account. + + API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of IPv6 pools on this account. + :rtype: PaginatedList of IPv6Pool + """ + return self.client._get_and_filter(IPv6Pool, *filters) def vlans(self, *filters): """ .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + Returns a list of VLANs on your account. - :returns: A Paginated List of VLANs on your account. + API Documentation: https://www.linode.com/docs/api/networking/#vlans-list + + :param filters: Any number of filters to apply to this query. + + :returns: A List of VLANs on your account. :rtype: PaginatedList of VLAN """ return self.client._get_and_filter(VLAN, *filters) @@ -912,6 +1097,7 @@ def ips_assign(self, region, *assignments): linode1.invalidate() linode2.invalidate() + API Documentation: https://www.linode.com/docs/api/networking/#linodes-assign-ipv4s :param region: The Region in which the assignments should take place. All Instances and IPAddresses involved in the assignment @@ -941,12 +1127,14 @@ def ip_allocate(self, linode, public=True): Allocates an IP to a Instance you own. Additional IPs must be requested by opening a support ticket first. + API Documentation: https://www.linode.com/docs/api/networking/#ip-address-allocate + :param linode: The Instance to allocate the new IP for. :type linode: Instance or int :param public: If True, allocate a public IP address. Defaults to True. :type public: bool - :returns: The new IPAddress + :returns: The new IPAddress. :rtype: IPAddress """ result = self.client.post( @@ -972,6 +1160,8 @@ def ips_share(self, linode, *ips): :any:`Instance`. This will enable the provided Instance to bring up the shared IP Addresses even though it does not own them. + API Documentation: https://www.linode.com/docs/api/networking/#ipv4-sharing-configure + :param linode: The Instance to share the IPAddresses with. This Instance will be able to bring up the given addresses. :type: linode: int or Instance @@ -1003,25 +1193,76 @@ def ips_share(self, linode, *ips): class SupportGroup(Group): + """ + Collections related to support tickets. + """ + def tickets(self, *filters): + """ + Returns a list of support tickets on this account. + + API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of support tickets on this account. + :rtype: PaginatedList of SupportTicket + """ + return self.client._get_and_filter(SupportTicket, *filters) - def ticket_open(self, summary, description, regarding=None): - """ """ + def ticket_open( + self, + summary, + description, + managed_issue=False, + regarding=None, + **kwargs, + ): + """ + Opens a support ticket on this account. + + API Documentation: https://www.linode.com/docs/api/support/#support-ticket-open + + :param summary: The summary or title for this support ticket. + :type summary: str + :param description: The full details of the issue or question. + :type description: str + :param regarding: The resource being referred to in this ticket. + :type regarding: + :param managed_issue: Designates if this ticket relates to a managed service. + :type managed_issue: bool + + :returns: The new support ticket. + :rtype: SupportTicket + """ params = { "summary": summary, "description": description, + "managed_issue": managed_issue, + } + + type_to_id = { + Instance: "linode_id", + Domain: "domain_id", + NodeBalancer: "nodebalancer_id", + Volume: "volume_id", + Firewall: "firewall_id", + LKECluster: "lkecluster_id", + Database: "database_id", + LongviewClient: "longviewclient_id", } + params.update(kwargs) + if regarding: - if isinstance(regarding, Instance): - params["linode_id"] = regarding.id - elif isinstance(regarding, Domain): - params["domain_id"] = regarding.id - elif isinstance(regarding, NodeBalancer): - params["nodebalancer_id"] = regarding.id - elif isinstance(regarding, Volume): - params["volume_id"] = regarding.id + id_attr = type_to_id.get(type(regarding)) + + if id_attr is not None: + params[id_attr] = regarding.id + elif isinstance(regarding, VLAN): + params["vlan"] = regarding.label + params["region"] = regarding.region else: raise ValueError( "Cannot open ticket regarding type {}!".format( @@ -1053,6 +1294,8 @@ def clusters(self, *filters): us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") + API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list + :param filters: Any number of filters to apply to this query. :returns: A list of Object Storage Clusters that matched the query. @@ -1065,6 +1308,8 @@ def keys(self, *filters): Returns a list of Object Storage Keys active on this account. These keys allow third-party applications to interact directly with Linode Object Storage. + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list + :param filters: Any number of filters to apply to this query. :returns: A list of Object Storage Keys that matched the query. @@ -1107,6 +1352,8 @@ def keys_create(self, label, bucket_access=None): bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), ) + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create + :param label: The label for this keypair, for identification only. :type label: str :param bucket_access: One or a list of dicts with keys "cluster," @@ -1180,6 +1427,8 @@ def cancel(self): Cancels Object Storage service. This may be a destructive operation. Once cancelled, you will no longer receive the transfer for or be billed for Object Storage, and all keys will be invalidated. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel """ self.client.post("/object-storage/cancel", data={}) return True @@ -1206,6 +1455,8 @@ def types(self, *filters): database_types = client.database.types(DatabaseType.deprecated == False) + API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list + :param filters: Any number of filters to apply to the query. :returns: A list of types that match the query. @@ -1222,6 +1473,8 @@ def engines(self, *filters): mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql') + API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list + :param filters: Any number of filters to apply to the query. :returns: A list of types that match the query. @@ -1233,6 +1486,8 @@ def instances(self, *filters): """ Returns a list of Managed Databases active on this account. + API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all + :param filters: Any number of filters to apply to this query. :returns: A list of databases that matched the query. @@ -1244,6 +1499,8 @@ def mysql_instances(self, *filters): """ Returns a list of Managed MySQL Databases active on this account. + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list + :param filters: Any number of filters to apply to this query. :returns: A list of MySQL databases that matched the query. @@ -1271,6 +1528,8 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): type.id ) + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-create + :param label: The name for this cluster :type label: str :param region: The region to deploy this cluster in @@ -1303,6 +1562,8 @@ def postgresql_instances(self, *filters): """ Returns a list of Managed PostgreSQL Databases active on this account. + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list + :param filters: Any number of filters to apply to this query. :returns: A list of PostgreSQL databases that matched the query. @@ -1330,6 +1591,8 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): type.id ) + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-create + :param label: The name for this cluster :type label: str :param region: The region to deploy this cluster in @@ -1365,6 +1628,8 @@ def mongodb_instances(self, *filters): """ Returns a list of Managed MongoDB Databases active on this account. + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-databases-list + :param filters: Any number of filters to apply to this query. :returns: A list of MongoDB databases that matched the query. @@ -1392,6 +1657,8 @@ def mongodb_create(self, label, region, engine, ltype, **kwargs): type.id ) + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-create + :param label: The name for this cluster :type label: str :param region: The region to deploy this cluster in @@ -1673,6 +1940,8 @@ def regions(self, *filters): """ Returns the available Regions for Linode products. + API Documentation: https://www.linode.com/docs/api/regions/#regions-list + :param filters: Any number of filters to apply to the query. :returns: A list of available Regions. @@ -1689,6 +1958,8 @@ def images(self, *filters): debian_images = client.images( Image.vendor == "debain") + API Documentation: https://www.linode.com/docs/api/images/#images-list + :param filters: Any number of filters to apply to the query. :returns: A list of available Images. @@ -1700,6 +1971,8 @@ def image_create(self, disk, label=None, description=None): """ Creates a new Image from a disk you own. + API Documentation: https://www.linode.com/docs/api/images/#image-create + :param disk: The Disk to imagize. :type disk: Disk or int :param label: The label for the resulting Image (defaults to the disk's @@ -1737,7 +2010,8 @@ def image_create_upload( ) -> Tuple[Image, str]: """ Creates a new Image and returns the corresponding upload URL. - https://www.linode.com/docs/api/images/#image-upload + + API Documentation: https://www.linode.com/docs/api/images/#image-upload :param label: The label of the Image to create. :type label: str @@ -1768,7 +2042,8 @@ def image_upload( ) -> Image: """ Creates and uploads a new image. - https://www.linode.com/docs/api/images/#image-upload + + API Documentation: https://www.linode.com/docs/api/images/#image-upload :param label: The label of the Image to create. :type label: str @@ -1800,6 +2075,8 @@ def domains(self, *filters): """ Retrieves all of the Domains the acting user has access to. + API Documentation: https://www.linode.com/docs/api/domains/#domains-list + :param filters: Any number of filters to apply to this query. :returns: A list of Domains the acting user can access. @@ -1811,6 +2088,8 @@ def nodebalancers(self, *filters): """ Retrieves all of the NodeBalancers the acting user has access to. + API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list + :param filters: Any number of filters to apply to this query. :returns: A list of NodeBalancers the acting user can access. @@ -1822,6 +2101,8 @@ def nodebalancer_create(self, region, **kwargs): """ Creates a new NodeBalancer in the given Region. + API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-create + :param region: The Region in which to create the NodeBalancer. :type region: Region or str @@ -1849,6 +2130,8 @@ def domain_create(self, domain, master=True, **kwargs): your registrar to Linode's nameservers so that Linode's DNS manager will correctly serve your domain. + API Documentation: https://www.linode.com/docs/api/domains/#domain-create + :param domain: The domain to register to Linode's DNS manager. :type domain: str :param master: Whether this is a master (defaults to true) @@ -1882,6 +2165,8 @@ def tags(self, *filters): Retrieves the Tags on your account. This may only be attempted by unrestricted users. + API Documentation: https://www.linode.com/docs/api/domains/#domain-create + :param filters: Any number of filters to apply to this query. :returns: A list of Tags on the account. @@ -1901,6 +2186,8 @@ def tag_create( """ Creates a new Tag and optionally applies it to the given entities. + API Documentation: https://www.linode.com/docs/api/tags/#tags-list + :param label: The label for the new Tag :type label: str :param entities: A list of objects to apply this Tag to upon creation. @@ -1981,6 +2268,8 @@ def volumes(self, *filters): """ Retrieves the Block Storage Volumes your user has access to. + API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list + :param filters: Any number of filters to apply to this query. :returns: A list of Volumes the acting user can access. @@ -1993,6 +2282,8 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): Creates a new Block Storage Volume, either in the given Region or attached to the given Instance. + API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list + :param label: The label for the new Volume. :type label: str :param region: The Region to create this Volume in. Not required if From fc15ee07fc35cdfb67ce8aefcc039232d44b2e29 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Tue, 18 Apr 2023 15:00:46 -0400 Subject: [PATCH 071/379] Added missing fields and endpoints for support-related funcitonality (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added missing fields and endpoints for support-related funcitonality ## ✔️ How to Test Run `tox`. Ticket: TPT-1896 --- linode_api4/objects/support.py | 7 +++++++ test/fixtures/support_tickets_123.json | 22 ++++++++++++++++++++ test/objects/support_test.py | 28 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 test/fixtures/support_tickets_123.json create mode 100644 test/objects/support_test.py diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index 965346b8f..c12def1a1 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -40,6 +40,10 @@ class SupportTicket(Base): "updated": Property(is_datetime=True), "updated_by": Property(), "replies": Property(derived_class=TicketReply), + "attachments": Property(), + "closable": Property(), + "gravatar_id": Property(), + "opened_by": Property(), } @property @@ -114,3 +118,6 @@ def upload_attachment(self, attachment): raise ApiError("{}: {}".format(result.status_code, errors), json=j) return True + + def support_ticket_close(self): + self._client.post("{}/close".format(self.api_endpoint), model=self) diff --git a/test/fixtures/support_tickets_123.json b/test/fixtures/support_tickets_123.json new file mode 100644 index 000000000..4a568f111 --- /dev/null +++ b/test/fixtures/support_tickets_123.json @@ -0,0 +1,22 @@ +{ + "attachments": [ + null + ], + "closable": false, + "closed": "2015-06-04T16:07:03", + "description": "I'm having trouble setting the root password on my Linode. I tried following the instructions but something is not working and I'm not sure what I'm doing wrong. Can you please help me figure out how I can reset it?\n", + "entity": { + "id": 10400, + "label": "linode123456", + "type": "linode", + "url": "/v4/linode/instances/123456" + }, + "gravatar_id": "474a1b7373ae0be4132649e69c36ce30", + "id": 123, + "opened": "2015-06-04T14:16:44", + "opened_by": "some_user", + "status": "open", + "summary": "Having trouble resetting root password on my Linode\n", + "updated": "2015-06-04T16:07:03", + "updated_by": "some_other_user" + } diff --git a/test/objects/support_test.py b/test/objects/support_test.py new file mode 100644 index 000000000..50ca0f9b9 --- /dev/null +++ b/test/objects/support_test.py @@ -0,0 +1,28 @@ +from test.base import ClientBaseCase + +from linode_api4.objects import SupportTicket + + +class SupportTest(ClientBaseCase): + """ + Tests methods of the SupportTicket class + """ + + def test_get_support_ticket(self): + ticket = SupportTicket(self.client, 123) + + self.assertIsNotNone(ticket.attachments) + self.assertFalse(ticket.closable) + self.assertIsNotNone(ticket.entity) + self.assertEqual(ticket.gravatar_id, "474a1b7373ae0be4132649e69c36ce30") + self.assertEqual(ticket.id, 123) + self.assertEqual(ticket.opened_by, "some_user") + self.assertEqual(ticket.status, "open") + self.assertEqual(ticket.updated_by, "some_other_user") + + def test_support_ticket_close(self): + ticket = SupportTicket(self.client, 123) + + with self.mock_post({}) as m: + ticket.support_ticket_close() + self.assertEqual(m.call_url, "/support/tickets/123/close") From dcdb362e43e10a011411a51b32e9cedca492a195 Mon Sep 17 00:00:00 2001 From: John Callahan <114753608+jcallahan-akamai@users.noreply.github.com> Date: Tue, 18 Apr 2023 16:00:17 -0400 Subject: [PATCH 072/379] Fix broken links in README.rst (#251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** This fixes the broken docs links. ## ✔️ How to Test **What are the steps to reproduce the issue or verify the changes?** Click on both docs links to ensure they work correctly. --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 3cd7acacb..9b8430b57 100644 --- a/README.rst +++ b/README.rst @@ -84,8 +84,8 @@ Check out the `Getting Started guide`_ for more details on getting started with this library, or read `the docs`_ for more extensive documentation. .. _Linode Personal Access Token: https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/ -.. _Getting Started guide: http://linode_api4.readthedocs.io/en/latest/guides/getting_started.html -.. _the docs: http://linode_api4.readthedocs.io/en/latest/index.html +.. _Getting Started guide: http://linode-api4.readthedocs.io/en/latest/guides/getting_started.html +.. _the docs: http://linode-api4.readthedocs.io/en/latest/index.html Examples -------- From fc16b5f64d5b01c5958f188c40743d94a343ecd3 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 19 Apr 2023 11:21:32 -0400 Subject: [PATCH 073/379] fix: Correct typo in LongviewSubscription API endpoint (#249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change corrects a small typo in the LongviewSubscription that would cause 404 errors to be returned from the API. Resolves #116 ## ✔️ How to Test ``` tox ``` --- linode_api4/objects/longview.py | 3 ++- test/linode_client_test.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py index 35b159380..cfbb3984d 100644 --- a/linode_api4/objects/longview.py +++ b/linode_api4/objects/longview.py @@ -16,7 +16,8 @@ class LongviewClient(Base): class LongviewSubscription(Base): - api_endpoint = "longview/subscriptions/{id}" + api_endpoint = "/longview/subscriptions/{id}" + properties = { "id": Property(identifier=True), "label": Property(), diff --git a/test/linode_client_test.py b/test/linode_client_test.py index d1e444c49..6e2bcae7c 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -396,7 +396,10 @@ def test_get_subscriptions(self): """ Tests that Longview subscriptions can be retrieved """ - r = self.client.longview.subscriptions() + + with self.mock_get("longview/subscriptions") as m: + r = self.client.longview.subscriptions() + self.assertEqual(m.call_url, "/longview/subscriptions") self.assertEqual(len(r), 4) From 8ae530e5d7a64858fe6b47b80b655fd44b5a60e9 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 19 Apr 2023 11:21:48 -0400 Subject: [PATCH 074/379] doc: Add clarifying documentation for `LinodeClient.load()` (#247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds some documentation of the `Core Concepts` guide to clarify the advantages and uses of `LinodeClient.load()`. Resolves #184 --- docs/guides/core_concepts.rst | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/guides/core_concepts.rst b/docs/guides/core_concepts.rst index 22b0bbcfc..535d8079a 100644 --- a/docs/guides/core_concepts.rst +++ b/docs/guides/core_concepts.rst @@ -82,6 +82,7 @@ In addition to looking up models from collections, you can simply import the model class and create it by ID.:: from linode_api4 import Instance + my_linode = Instance(client, 123) All models take a `LinodeClient` as their first parameter, and their ID as the @@ -94,6 +95,14 @@ Be aware that when creating a model this way, it is _not_ loaded from the API immediately. Models in this library are **lazy-loaded**, and will not be looked up until one of their attributes that is currently unknown is accessed. +In order to automatically populate a model for an existing Linode resource, +consider using the :any:`LinodeClient.load` method:: + + from linode_api4 import Instance, Disk + + instance = client.load(Instance, 12345) + instance_disk = client.load(Disk, 123, instance.id) + Lazy Loading ^^^^^^^^^^^^ @@ -149,10 +158,15 @@ models can also be deleted in a similar fashion.:: .. note:: Saving a model *may* fail if the values you are attempting to save are invalid. - If the values you are attemting to save are coming from an untrusted source, + If the values you are attempting to save are coming from an untrusted source, be sure to handle a potential :any:`ApiError` raised by the API returning an unsuccessful response code. + When updating an attribute on a model, ensure that the model has been populated + *before* any local changes have been made. Attempting to update an attribute + and save a model before the model has been populated will result in no changes + being applied. + Relationships ^^^^^^^^^^^^^ From afdf53f9b7e2389508e0563869d0f9799a79844f Mon Sep 17 00:00:00 2001 From: Will Smith Date: Wed, 19 Apr 2023 11:22:12 -0400 Subject: [PATCH 075/379] bug: Fixed splitting OAuth Scopes on wrong character (#220) Closes #219 Nothing makes me think that this changed; it must've never worked. The [example app](https://github.com/linode/linode_api4-python/blob/1c65238ed6a41dd011f039b3e4cda7dd58f8c4a9/examples/install-on-linode/app.py#L50) works fine because it only includes a single OAuth Scope; had it used two, it would've encountered this issue too. --------- Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Co-authored-by: Lena Garber --- .github/workflows/main.yml | 2 +- linode_api4/login_client.py | 5 ++-- test/login_client_test.py | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 test/login_client_test.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8c425fe13..ae876b72a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7','3.8','3.9','3.10', '3.11'] + python-version: ['3.7','3.8','3.9','3.10','3.11'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 68de22f5f..7531a7725 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -1,3 +1,4 @@ +import re from datetime import datetime, timedelta from enum import Enum @@ -283,7 +284,7 @@ def parse(scopes): for scope in OAuthScopes._scope_families.values() ] - for scope in scopes.split(","): + for scope in re.split("[, ]", scopes): resource = access = None if ":" in scope: resource, access = scope.split(":") @@ -303,7 +304,7 @@ def _get_parsed_scope(resource, access): access = access.lower() if resource in OAuthScopes._scope_families: if access == "*": - access = "delete" + access = "all" if hasattr(OAuthScopes._scope_families[resource], access): return getattr(OAuthScopes._scope_families[resource], access) diff --git a/test/login_client_test.py b/test/login_client_test.py new file mode 100644 index 000000000..5a17d77c1 --- /dev/null +++ b/test/login_client_test.py @@ -0,0 +1,55 @@ +from unittest import TestCase + +from linode_api4 import OAuthScopes + + +class OAuthScopesTest(TestCase): + def test_parse_scopes_none(self): + """ + Tests parsing no scopes + """ + scopes = OAuthScopes.parse("") + self.assertEqual(scopes, []) + + def test_parse_scopes_single(self): + """ + Tests parsing a single scope + """ + scopes = OAuthScopes.parse("linodes:read_only") + self.assertEqual(scopes, [OAuthScopes.Linodes.read_only]) + + def test_parse_scopes_many(self): + """ + Tests parsing many scopes + """ + scopes = OAuthScopes.parse("linodes:read_only domains:read_write") + self.assertEqual( + scopes, + [OAuthScopes.Linodes.read_only, OAuthScopes.Domains.read_write], + ) + + def test_parse_scopes_many_comma_delimited(self): + """ + Tests parsing many scopes that are comma-delimited (which preserves old behavior) + """ + scopes = OAuthScopes.parse( + "nodebalancers:read_write,stackscripts:*,events:read_only" + ) + self.assertEqual( + scopes, + [ + OAuthScopes.NodeBalancers.read_write, + OAuthScopes.StackScripts.all, + OAuthScopes.Events.read_only, + ], + ) + + def test_parse_scopes_all(self): + """ + Tests parsing * scopes + """ + scopes = OAuthScopes.parse("*") + self.assertEqual( + scopes, + [getattr(c, "all") for c in OAuthScopes._scope_families.values()], + ) From a3d282e5658dfa7c1824d454aeb8a177e3881833 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 19 Apr 2023 11:24:08 -0400 Subject: [PATCH 076/379] doc: Add small note to README documentation (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds a small example of using `sphinx-autobuild` to preview project documentation. This is necessary as the intended usage of this command was not previously apparent. Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- README.rst | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 9b8430b57..e009ab4bd 100644 --- a/README.rst +++ b/README.rst @@ -138,7 +138,12 @@ Documentation This library is documented with Sphinx_. Docs live in the ``docs`` directory. The easiest way to build the docs is to run ``sphinx-autobuild`` in that -folder. +folder:: + + sphinx-autobuild docs docs/build + +After running this command, ``sphinx-autobuild`` will host a local web server +with the rendered documentation. Classes and functions inside the library should be annotated with sphinx-compliant docstrings which will be used to automatically generate documentation for the From 9feacae21fcaf8c543f8a96b872b37f396441ec9 Mon Sep 17 00:00:00 2001 From: Will Smith Date: Wed, 19 Apr 2023 13:23:27 -0400 Subject: [PATCH 077/379] Add field hint to error messages (#211) Relates to #208 In the above issue, it wasn't obvious what the API was complaining about. This is because the API sends more than just a message for most errors; it also sends back the "field" the error applied to. We were hiding that for users (unintentionally) in this library. This change adds the field to errors reported via this library, which should help improve debugging of errors returned by the API. A new error looks like this: ``` ApiError: 400: [group] Length must be 1-50 characters; ``` For errors with no field, they return as they did before: ``` ApiError: 405: Method Not Allowed; ``` --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Zhiwei Liang --- linode_api4/linode_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index decf9c448..f7248304b 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1876,10 +1876,12 @@ def _api_call( j = response.json() if "errors" in j.keys(): for e in j["errors"]: - error_msg += ( - "{}; ".format(e["reason"]) - if "reason" in e.keys() - else "" + msg = e.get("reason", "") + field = e.get("field", None) + + error_msg += "{}{}; ".format( + f"[{field}] " if field is not None else "", + msg, ) except: pass From d43ffa905f0eeddd63f4a2eacfb8e10cf4fd9e47 Mon Sep 17 00:00:00 2001 From: Erik Nygren Date: Wed, 19 Apr 2023 13:27:00 -0400 Subject: [PATCH 078/379] fix readthedocs links (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readthedocs links return 400 errors (at least in FireFox) when an underscore is in the hostname. (Underscores are invalid in hostnames.) Also switch them to be https. ## 📝 Description Fix broken URL in README ## ✔️ How to Test Follow README links that have changed. Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index e009ab4bd..f04105bdf 100644 --- a/README.rst +++ b/README.rst @@ -84,8 +84,8 @@ Check out the `Getting Started guide`_ for more details on getting started with this library, or read `the docs`_ for more extensive documentation. .. _Linode Personal Access Token: https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/ -.. _Getting Started guide: http://linode-api4.readthedocs.io/en/latest/guides/getting_started.html -.. _the docs: http://linode-api4.readthedocs.io/en/latest/index.html +.. _Getting Started guide: https://linode-api4.readthedocs.io/en/latest/guides/getting_started.html +.. _the docs: https://linode-api4.readthedocs.io/en/latest/index.html Examples -------- From ca74935f0f50a5511aeefa03e8211be13e680482 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Thu, 20 Apr 2023 13:12:59 -0400 Subject: [PATCH 079/379] Brought domain-related functionality to API Parity (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Brought domain-related functionality to API Parity ## ✔️ How to Test Run `tox`. Ticket: TPT-1884 --- linode_api4/objects/domain.py | 24 +++++++++++++ test/fixtures/domains_12345_clone.json | 19 ++++++++++ test/fixtures/domains_12345_records.json | 15 ++++++++ test/fixtures/domains_12345_zone-file.json | 12 +++++++ test/fixtures/domains_import.json | 19 ++++++++++ test/objects/domain_test.py | 42 +++++++++++++++++++++- 6 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/domains_12345_clone.json create mode 100644 test/fixtures/domains_12345_records.json create mode 100644 test/fixtures/domains_12345_zone-file.json create mode 100644 test/fixtures/domains_import.json diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index 8e769340b..8c151a2a4 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -20,6 +20,8 @@ class DomainRecord(DerivedBase): "protocol": Property(mutable=True), "ttl_sec": Property(mutable=True), "tag": Property(mutable=True), + "created": Property(), + "updated": Property(), } @@ -61,3 +63,25 @@ def record_create(self, record_type, **kwargs): zr = DomainRecord(self._client, result["id"], self.id, result) return zr + + def zone_file_view(self): + result = self._client.get( + "{}/zone-file".format(self.api_endpoint), model=self + ) + + return result["zone_file"] + + def clone(self, domain: str): + params = {"domain": domain} + + self._client.post( + "{}/clone".format(self.api_endpoint), model=self, data=params + ) + + def domain_import(self, domain, remote_nameserver): + params = { + "domain": domain.domain if isinstance(domain, Domain) else domain, + "remote_nameserver": remote_nameserver, + } + + self._client.post("/domains/import", model=self, data=params) diff --git a/test/fixtures/domains_12345_clone.json b/test/fixtures/domains_12345_clone.json new file mode 100644 index 000000000..faf3e7c28 --- /dev/null +++ b/test/fixtures/domains_12345_clone.json @@ -0,0 +1,19 @@ +{ + "axfr_ips": [], + "description": null, + "domain": "example.org", + "expire_sec": 300, + "group": null, + "id": 1234, + "master_ips": [], + "refresh_sec": 300, + "retry_sec": 300, + "soa_email": "admin@example.org", + "status": "active", + "tags": [ + "example tag", + "another example" + ], + "ttl_sec": 300, + "type": "master" +} diff --git a/test/fixtures/domains_12345_records.json b/test/fixtures/domains_12345_records.json new file mode 100644 index 000000000..fe90f3282 --- /dev/null +++ b/test/fixtures/domains_12345_records.json @@ -0,0 +1,15 @@ +{ + "created": "2018-01-01T00:01:01", + "id": 123456, + "name": "test", + "port": 80, + "priority": 50, + "protocol": null, + "service": null, + "tag": null, + "target": "192.0.2.0", + "ttl_sec": 604800, + "type": "A", + "updated": "2018-01-01T00:01:01", + "weight": 50 +} \ No newline at end of file diff --git a/test/fixtures/domains_12345_zone-file.json b/test/fixtures/domains_12345_zone-file.json new file mode 100644 index 000000000..7cb4ad591 --- /dev/null +++ b/test/fixtures/domains_12345_zone-file.json @@ -0,0 +1,12 @@ +{ + "zone_file": [ + "; example.com [123]", + "$TTL 864000", + "@ IN SOA ns1.linode.com. user.example.com. 2021000066 14400 14400 1209600 86400", + "@ NS ns1.linode.com.", + "@ NS ns2.linode.com.", + "@ NS ns3.linode.com.", + "@ NS ns4.linode.com.", + "@ NS ns5.linode.com." + ] +} diff --git a/test/fixtures/domains_import.json b/test/fixtures/domains_import.json new file mode 100644 index 000000000..f1a254afc --- /dev/null +++ b/test/fixtures/domains_import.json @@ -0,0 +1,19 @@ +{ + "axfr_ips": [], + "description": null, + "domain": "example.org", + "expire_sec": 300, + "group": null, + "id": 1234, + "master_ips": [], + "refresh_sec": 300, + "retry_sec": 300, + "soa_email": "admin@example.org", + "status": "active", + "tags": [ + "example tag", + "another example" + ], + "ttl_sec": 300, + "type": "master" +} \ No newline at end of file diff --git a/test/objects/domain_test.py b/test/objects/domain_test.py index ded8321db..003058af8 100644 --- a/test/objects/domain_test.py +++ b/test/objects/domain_test.py @@ -1,6 +1,6 @@ from test.base import ClientBaseCase -from linode_api4.objects import Domain +from linode_api4.objects import Domain, DomainRecord class DomainGeneralTest(ClientBaseCase): @@ -8,6 +8,11 @@ class DomainGeneralTest(ClientBaseCase): Tests methods of the Domain class. """ + def test_domain_get(self): + domain_record = DomainRecord(self.client, 123456, 12345) + + self.assertEqual(domain_record.id, 123456) + def test_save_null_values_excluded(self): with self.mock_put("domains/12345") as m: domain = self.client.load(Domain, 12345) @@ -17,3 +22,38 @@ def test_save_null_values_excluded(self): domain.save() self.assertTrue("group" not in m.call_data.keys()) + + def test_zone_file_view(self): + domain = Domain(self.client, 12345) + + with self.mock_get("/domains/12345/zone-file") as m: + result = domain.zone_file_view() + self.assertEqual(m.call_url, "/domains/12345/zone-file") + self.assertIsNotNone(result) + + def test_clone(self): + domain = Domain(self.client, 12345) + + with self.mock_post("/domains/12345/clone") as m: + domain.clone("example.org") + self.assertEqual(m.call_url, "/domains/12345/clone") + self.assertEqual(m.call_data["domain"], "example.org") + + def test_import(self): + domain = Domain(self.client, 12345) + + with self.mock_post("/domains/import") as m: + domain.domain_import("example.org", "examplenameserver.com") + self.assertEqual(m.call_url, "/domains/import") + self.assertEqual(m.call_data["domain"], "example.org") + self.assertEqual( + m.call_data["remote_nameserver"], "examplenameserver.com" + ) + + with self.mock_post("/domains/import") as m: + domain.domain_import(domain, "examplenameserver.com") + self.assertEqual(m.call_url, "/domains/import") + self.assertEqual(m.call_data["domain"], "example.org") + self.assertEqual( + m.call_data["remote_nameserver"], "examplenameserver.com" + ) From cbd422462c8408c9f928fede2a19cfa6012dd7ce Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Fri, 21 Apr 2023 11:12:47 -0400 Subject: [PATCH 080/379] Added missing account-related fields and endpoints (#244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added missing account-related fields and endpoints bringing account-related functionality to API parity. ## ✔️ How to Test Run `tox`. Ticket: TPT-1903 --- linode_api4/linode_client.py | 207 ++++++++++++++++++ linode_api4/objects/account.py | 93 +++++++- test/fixtures/account.json | 18 +- test/fixtures/account_events_123.json | 27 +++ test/fixtures/account_invoices_123.json | 14 ++ test/fixtures/account_logins.json | 15 ++ test/fixtures/account_logins_123.json | 8 + test/fixtures/account_maintenance.json | 19 ++ test/fixtures/account_notifications.json | 22 ++ ...nt_oauth-clients_2737bf16b39ab5d7b4a1.json | 9 + test/fixtures/account_payment-method_123.json | 12 + test/fixtures/account_payment-methods.json | 18 ++ test/fixtures/account_promo-codes.json | 10 + test/fixtures/account_service-transfers.json | 21 ++ .../account_service-transfers_12345.json | 14 ++ test/fixtures/account_settings.json | 3 +- test/fixtures/account_users_test-user.json | 10 + test/linode_client_test.py | 108 +++++++++ test/objects/account_test.py | 200 ++++++++++++++++- 19 files changed, 824 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/account_events_123.json create mode 100644 test/fixtures/account_invoices_123.json create mode 100644 test/fixtures/account_logins.json create mode 100644 test/fixtures/account_logins_123.json create mode 100644 test/fixtures/account_maintenance.json create mode 100644 test/fixtures/account_notifications.json create mode 100644 test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json create mode 100644 test/fixtures/account_payment-method_123.json create mode 100644 test/fixtures/account_payment-methods.json create mode 100644 test/fixtures/account_promo-codes.json create mode 100644 test/fixtures/account_service-transfers.json create mode 100644 test/fixtures/account_service-transfers_12345.json create mode 100644 test/fixtures/account_users_test-user.json diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index f7248304b..5ad88b7a3 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -871,6 +871,213 @@ def users(self, *filters): """ return self.client._get_and_filter(User, *filters) + def logins(self): + """ + Returns a collection of successful logins for all users on the account during the last 90 days. + + API Documentation: https://www.linode.com/docs/api/account/#user-logins-list-all + + :returns: A list of Logins on this account. + :rtype: PaginatedList of Login + """ + + return self.client._get_and_filter(Login) + + 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://www.linode.com/docs/api/account/#user-logins-list-all + + :returns: A list of Maintenance objects on this account. + :rtype: List of Maintenance objects as MappedObjects + """ + + result = self.client.get( + "{}/maintenance".format(Account.api_endpoint), model=self + ) + + return [MappedObject(**r) for r in result["data"]] + + def payment_methods(self): + """ + Returns a list of Payment Methods for this Account. + + API Documentation: https://www.linode.com/docs/api/account/#payment-methods-list + + :returns: A list of Payment Methods on this account. + :rtype: PaginatedList of PaymentMethod + """ + + return self.client._get_and_filter(PaymentMethod) + + def add_payment_method(self, data, is_default, type): + """ + Adds a Payment Method to your Account with the option to set it as the default method. + + API Documentation: https://www.linode.com/docs/api/account/#payment-method-add + + :param data: An object representing the credit card information you have on file with + Linode to make Payments against your Account. + :type data: dict + + Example usage:: + data = { + "card_number": "4111111111111111", + "expiry_month": 11, + "expiry_year": 2020, + "cvv": "111" + } + + :param is_default: Whether this Payment Method is the default method for + automatically processing service charges. + :type is_default: bool + + :param type: The type of Payment Method. Enum: ["credit_card] + :type type: str + """ + + if type != "credit_card": + raise ValueError("Unknown Payment Method type: {}".format(type)) + + if ( + "card_number" not in data + or "expiry_month" not in data + or "expiry_year" not in data + or "cvv" not in data + or not data + ): + raise ValueError("Invalid credit card info provided") + + params = {"data": data, "type": type, "is_default": is_default} + + resp = self.client.post( + "{}/payment-methods".format(Account.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when adding payment method!", + json=resp, + ) + + def notifications(self): + """ + Returns a collection of Notification objects representing important, often time-sensitive items related to your Account. + + API Documentation: https://www.linode.com/docs/api/account/#notifications-list + + :returns: A list of Notifications on this account. + :rtype: List of Notification objects as MappedObjects + """ + + result = self.client.get( + "{}/notifications".format(Account.api_endpoint), model=self + ) + + return [MappedObject(**r) for r in result["data"]] + + def linode_managed_enable(self): + """ + Enables Linode Managed for the entire account and sends a welcome email to the account’s associated email address. + + API Documentation: https://www.linode.com/docs/api/account/#linode-managed-enable + """ + + resp = self.client.post( + "{}/settings/managed-enable".format(Account.api_endpoint), + model=self, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when enabling Linode Managed!", + json=resp, + ) + + def add_promo_code(self, promo_code): + """ + Adds an expiring Promo Credit to your account. + + API Documentation: https://www.linode.com/docs/api/account/#promo-credit-add + + :param promo_code: The Promo Code. + :type promo_code: str + """ + + params = { + "promo_code": promo_code, + } + + resp = self.client.post( + "{}/promo-codes".format(Account.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when adding Promo Code!", + json=resp, + ) + + def service_transfers(self): + """ + Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer. + + API Documentation: https://www.linode.com/docs/api/account/#service-transfers-list + + :returns: A list of Service Transfers on this account. + :rtype: PaginatedList of ServiceTransfer + """ + + return self.client._get_and_filter(ServiceTransfer) + + def service_transfer_create(self, entities): + """ + Creates a transfer request for the specified services. + + API Documentation: https://www.linode.com/docs/api/account/#service-transfer-create + + :param entities: A collection of the services to include in this transfer request, separated by type. + :type entities: dict + + Example usage:: + entities = { + "linodes": [ + 111, + 222 + ] + } + """ + + if not entities: + raise ValueError("Entities must be provided!") + + bad_entries = [ + k for k, v in entities.items() if not isinstance(v, list) + ] + if len(bad_entries) > 0: + raise ValueError( + f"Got unexpected type for entity lists: {', '.join(bad_entries)}" + ) + + params = {"entities": entities} + + resp = self.client.post( + "{}/service-transfers".format(Account.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when creating Service Transfer!", + json=resp, + ) + def transfer(self): """ Returns a MappedObject containing the account's transfer pool data. diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index f3f07c11c..a00679680 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -39,6 +39,80 @@ class Account(Base): "tax_id": Property(mutable=True), "capabilities": Property(), "credit_card": Property(), + "active_promotions": Property(), + "active_since": Property(), + "balance_uninvoiced": Property(), + "billing_source": Property(), + "euuid": Property(), + } + + +class ServiceTransfer(Base): + api_endpoint = "/account/service-transfers/{token}" + id_attribute = "token" + properties = { + "token": Property(identifier=True), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "is_sender": Property(), + "expiry": Property(), + "status": Property(), + "entities": Property(), + } + + def service_transfer_accept(self): + """ + Accept a Service Transfer for the provided token to receive the services included in the transfer to your account. + """ + + resp = self._client.post( + "{}/accept".format(self.api_endpoint), + model=self, + ) + + if "errors" in resp: + raise UnexpectedResponseError( + "Unexpected response when accepting service transfer!", + json=resp, + ) + + +class PaymentMethod(Base): + api_endpoint = "/account/payment-methods/{id}" + properties = { + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "is_default": Property(), + "type": Property(), + "data": Property(), + } + + def payment_method_make_default(self): + """ + Make this Payment Method the default method for automatically processing payments. + """ + + resp = self._client.post( + "{}/make-default".format(self.api_endpoint), + model=self, + ) + + if "errors" in resp: + raise UnexpectedResponseError( + "Unexpected response when making payment method default!", + json=resp, + ) + + +class Login(Base): + api_endpoint = "/account/logins/{id}" + properties = { + "id": Property(identifier=True), + "datetime": Property(is_datetime=True), + "ip": Property(), + "restricted": Property(), + "status": Property(), + "username": Property(), } @@ -53,6 +127,7 @@ class AccountSettings(Base): slug_relationship=LongviewSubscription ), "object_storage": Property(), + "backups_enabled": Property(mutable=True), } @@ -72,6 +147,9 @@ class Event(Base): "time_remaining": Property(), "rate": Property(), "status": Property(), + "duration": Property(), + "secondary_entity": Property(), + "message": Property(), } @property @@ -152,6 +230,9 @@ class Invoice(Base): "date": Property(is_datetime=True), "total": Property(), "items": Property(derived_class=InvoiceItem), + "tax": Property(), + "tax_summary": Property(), + "subtotal": Property(), } @@ -164,7 +245,8 @@ class OAuthClient(Base): "secret": Property(), "redirect_uri": Property(mutable=True), "status": Property(), - "public": Property(), + "public": Property(mutable=True), + "thumbnail_url": Property(), } def reset_secret(self): @@ -262,6 +344,8 @@ class User(Base): "email": Property(), "username": Property(identifier=True, mutable=True), "restricted": Property(mutable=True), + "ssh_keys": Property(), + "tfa_enabled": Property(), } @property @@ -298,6 +382,11 @@ def get_obj_grants(): """ Returns Grant keys mapped to Object types. """ + from linode_api4.objects import ( # pylint: disable=import-outside-toplevel + Database, + Firewall, + ) + return ( ("linode", Instance), ("domain", Domain), @@ -306,6 +395,8 @@ def get_obj_grants(): ("volume", Volume), ("image", Image), ("longview", LongviewClient), + ("database", Database), + ("firewall", Firewall), ) diff --git a/test/fixtures/account.json b/test/fixtures/account.json index ef292d932..1d823798b 100644 --- a/test/fixtures/account.json +++ b/test/fixtures/account.json @@ -17,5 +17,21 @@ "NodeBalancers", "Block Storage", "Object Storage" - ] + ], + "active_promotions": [ + { + "credit_monthly_cap": "10.00", + "credit_remaining": "50.00", + "description": "Receive up to $10 off your services every month for 6 months! Unused credits will expire once this promotion period ends.", + "expire_dt": "2018-01-31T23:59:59", + "image_url": "https://linode.com/10_a_month_promotion.svg", + "service_type": "all", + "summary": "$10 off your Linode a month!", + "this_month_credit_remaining": "10.00" + } + ], + "active_since": "2018-01-01T00:01:01", + "balance_uninvoiced": 145, + "billing_source": "akamai", + "euuid": "E1AF5EEC-526F-487D-B317EBEB34C87D71" } diff --git a/test/fixtures/account_events_123.json b/test/fixtures/account_events_123.json new file mode 100644 index 000000000..4c2b7141d --- /dev/null +++ b/test/fixtures/account_events_123.json @@ -0,0 +1,27 @@ +{ + "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 diff --git a/test/fixtures/account_invoices_123.json b/test/fixtures/account_invoices_123.json new file mode 100644 index 000000000..e20fe4de6 --- /dev/null +++ b/test/fixtures/account_invoices_123.json @@ -0,0 +1,14 @@ +{ + "date": "2018-01-01T00:01:01", + "id": 123, + "label": "Invoice", + "subtotal": 120.25, + "tax": 12.25, + "tax_summary": [ + { + "name": "PA STATE TAX", + "tax": 12.25 + } + ], + "total": 132.5 +} diff --git a/test/fixtures/account_logins.json b/test/fixtures/account_logins.json new file mode 100644 index 000000000..9c54581b1 --- /dev/null +++ b/test/fixtures/account_logins.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "datetime": "2018-01-01T00:01:01", + "id": 1234, + "ip": "192.0.2.0", + "restricted": true, + "status": "successful", + "username": "test-user" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/account_logins_123.json b/test/fixtures/account_logins_123.json new file mode 100644 index 000000000..3ec95d1ba --- /dev/null +++ b/test/fixtures/account_logins_123.json @@ -0,0 +1,8 @@ +{ + "datetime": "2018-01-01T00:01:01", + "id": 123, + "ip": "192.0.2.0", + "restricted": true, + "status": "successful", + "username": "test-user" +} \ No newline at end of file diff --git a/test/fixtures/account_maintenance.json b/test/fixtures/account_maintenance.json new file mode 100644 index 000000000..aeeab91e6 --- /dev/null +++ b/test/fixtures/account_maintenance.json @@ -0,0 +1,19 @@ +{ + "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 +} \ No newline at end of file diff --git a/test/fixtures/account_notifications.json b/test/fixtures/account_notifications.json new file mode 100644 index 000000000..7e6355221 --- /dev/null +++ b/test/fixtures/account_notifications.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "body": null, + "entity": { + "id": 3456, + "label": "Linode not booting.", + "type": "ticket", + "url": "/support/tickets/3456" + }, + "label": "You have an important ticket open!", + "message": "You have an important ticket open!", + "severity": "major", + "type": "ticket_important", + "until": null, + "when": null + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json b/test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json new file mode 100644 index 000000000..1520c8114 --- /dev/null +++ b/test/fixtures/account_oauth-clients_2737bf16b39ab5d7b4a1.json @@ -0,0 +1,9 @@ +{ + "id": "2737bf16b39ab5d7b4a1", + "label": "Test_Client_1", + "public": false, + "redirect_uri": "https://example.org/oauth/callback", + "secret": "", + "status": "active", + "thumbnail_url": "https://api.linode.com/v4/account/clients/2737bf16b39ab5d7b4a1/thumbnail" +} \ No newline at end of file diff --git a/test/fixtures/account_payment-method_123.json b/test/fixtures/account_payment-method_123.json new file mode 100644 index 000000000..611e49713 --- /dev/null +++ b/test/fixtures/account_payment-method_123.json @@ -0,0 +1,12 @@ +{ + "created": "2018-01-15T00:01:01", + "data": { + "card_type": "Discover", + "expiry": "06/2022", + "last_four": "1234" + }, + "id": 123, + "is_default": true, + "type": "credit_card" + } + \ No newline at end of file diff --git a/test/fixtures/account_payment-methods.json b/test/fixtures/account_payment-methods.json new file mode 100644 index 000000000..2619af248 --- /dev/null +++ b/test/fixtures/account_payment-methods.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "created": "2018-01-15T00:01:01", + "data": { + "card_type": "Discover", + "expiry": "06/2022", + "last_four": "1234" + }, + "id": 123, + "is_default": true, + "type": "credit_card" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/account_promo-codes.json b/test/fixtures/account_promo-codes.json new file mode 100644 index 000000000..838762934 --- /dev/null +++ b/test/fixtures/account_promo-codes.json @@ -0,0 +1,10 @@ +{ + "credit_monthly_cap": "10.00", + "credit_remaining": "50.00", + "description": "Receive up to $10 off your services every month for 6 months! Unused credits will expire once this promotion period ends.", + "expire_dt": "2018-01-31T23:59:59", + "image_url": "https://linode.com/10_a_month_promotion.svg", + "service_type": "all", + "summary": "$10 off your Linode a month!", + "this_month_credit_remaining": "10.00" + } \ No newline at end of file diff --git a/test/fixtures/account_service-transfers.json b/test/fixtures/account_service-transfers.json new file mode 100644 index 000000000..cbf4a0c60 --- /dev/null +++ b/test/fixtures/account_service-transfers.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "created": "2021-02-11T16:37:03", + "entities": { + "linodes": [ + 111, + 222 + ] + }, + "expiry": "2021-02-12T16:37:03", + "is_sender": true, + "status": "pending", + "token": "123E4567-E89B-12D3-A456-426614174000", + "updated": "2021-02-11T16:37:03" + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/account_service-transfers_12345.json b/test/fixtures/account_service-transfers_12345.json new file mode 100644 index 000000000..819506524 --- /dev/null +++ b/test/fixtures/account_service-transfers_12345.json @@ -0,0 +1,14 @@ +{ + "created": "2021-02-11T16:37:03", + "entities": { + "linodes": [ + 111, + 222 + ] + }, + "expiry": "2021-02-12T16:37:03", + "is_sender": true, + "status": "pending", + "token": "12345", + "updated": "2021-02-11T16:37:03" + } \ No newline at end of file diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json index a857d1750..77a2fdac3 100644 --- a/test/fixtures/account_settings.json +++ b/test/fixtures/account_settings.json @@ -2,5 +2,6 @@ "longview_subscription": "longview-100", "managed": false, "network_helper": false, - "object_storage": "active" + "object_storage": "active", + "backups_enabled": true } diff --git a/test/fixtures/account_users_test-user.json b/test/fixtures/account_users_test-user.json new file mode 100644 index 000000000..66e5f9b12 --- /dev/null +++ b/test/fixtures/account_users_test-user.json @@ -0,0 +1,10 @@ +{ + "email": "test-user@linode.com", + "restricted": true, + "ssh_keys": [ + "home-pc", + "laptop" + ], + "tfa_enabled": true, + "username": "test-user" + } \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 6e2bcae7c..e51d39ce0 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -285,6 +285,114 @@ def test_get_invoices(self): self.assertEqual(invoice.label, "Invoice #123456") self.assertEqual(invoice.total, 9.51) + def test_logins(self): + """ + Tests that logins can be retrieved + """ + logins = self.client.account.logins() + self.assertEqual(len(logins), 1) + self.assertEqual(logins[0].id, 1234) + + def test_maintenance(self): + """ + Tests that maintenance can be retrieved + """ + 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) + self.assertEqual( + result[0].reason, + "This maintenance will allow us to update the BIOS on the host's motherboard.", + ) + + def test_notifications(self): + """ + Tests that notifications can be retrieved + """ + with self.mock_get("/account/notifications") as m: + result = self.client.account.notifications() + self.assertEqual(m.call_url, "/account/notifications") + self.assertEqual(len(result), 1) + self.assertEqual( + result[0].label, "You have an important ticket open!" + ) + + def test_payment_methods(self): + """ + Tests that payment methods can be retrieved + """ + paymentMethods = self.client.account.payment_methods() + self.assertEqual(len(paymentMethods), 1) + self.assertEqual(paymentMethods[0].id, 123) + + def test_add_payment_method(self): + """ + Tests that adding a payment method creates the correct api request. + """ + with self.mock_post({}) as m: + self.client.account.add_payment_method( + { + "card_number": "123456789100", + "expiry_month": 1, + "expiry_year": 2028, + "cvv": 111, + }, + True, + "credit_card", + ) + self.assertEqual(m.call_url, "/account/payment-methods") + self.assertEqual(m.call_data["type"], "credit_card") + self.assertTrue(m.call_data["is_default"]) + self.assertIsNotNone(m.call_data["data"]) + + def test_add_promo_code(self): + """ + Tests that adding a promo code creates the correct api request. + """ + with self.mock_post("/account/promo-codes") as m: + self.client.account.add_promo_code("123promo456") + self.assertEqual(m.call_url, "/account/promo-codes") + self.assertEqual(m.call_data["promo_code"], "123promo456") + + def test_service_transfers(self): + """ + Tests that service transfers can be retrieved + """ + serviceTransfers = self.client.account.service_transfers() + self.assertEqual(len(serviceTransfers), 1) + self.assertEqual( + serviceTransfers[0].token, "123E4567-E89B-12D3-A456-426614174000" + ) + + def test_linode_managed_enable(self): + """ + Tests that enabling linode managed creates the correct api request. + """ + with self.mock_post({}) as m: + self.client.account.linode_managed_enable() + self.assertEqual(m.call_url, "/account/settings/managed-enable") + + def test_service_transfer_create(self): + """ + Tests that creating a service transfer creates the correct api request. + """ + data = {"linodes": [111, 222]} + response = { + "created": "2021-02-11T16:37:03", + "entities": {"linodes": [111, 222]}, + "expiry": "2021-02-12T16:37:03", + "is_sender": True, + "status": "pending", + "token": "123E4567-E89B-12D3-A456-426614174000", + "updated": "2021-02-11T16:37:03", + } + + with self.mock_post(response) as m: + self.client.account.service_transfer_create(data) + self.assertEqual(m.call_url, "/account/service-transfers") + self.assertEqual(m.call_data["entities"], data) + def test_payments(self): """ Tests that payments can be retrieved diff --git a/test/objects/account_test.py b/test/objects/account_test.py index 03d07fbaa..4648de12d 100644 --- a/test/objects/account_test.py +++ b/test/objects/account_test.py @@ -1,7 +1,27 @@ from datetime import datetime from test.base import ClientBaseCase -from linode_api4.objects import Invoice +from linode_api4.objects import ( + Account, + AccountSettings, + Database, + Domain, + Event, + Firewall, + Image, + Instance, + Invoice, + Login, + LongviewClient, + NodeBalancer, + OAuthClient, + PaymentMethod, + ServiceTransfer, + StackScript, + User, + Volume, + get_obj_grants, +) class InvoiceTest(ClientBaseCase): @@ -10,6 +30,9 @@ class InvoiceTest(ClientBaseCase): """ def test_get_invoice(self): + """ + Tests that an invoice is loaded correctly by ID + """ invoice = Invoice(self.client, 123456) self.assertEqual(invoice._populated, False) @@ -42,3 +65,178 @@ def test_get_invoice_items(self): item.to_date, datetime(year=2015, month=1, day=1, hour=4, minute=59, second=59), ) + + def test_get_account(self): + """ + Tests that an account is loaded correctly by email + """ + account = Account(self.client, "support@linode.com", {}) + + self.assertEqual(account.email, "support@linode.com") + self.assertEqual(account.state, "PA") + self.assertEqual(account.city, "Philadelphia") + self.assertEqual(account.phone, "123-456-7890") + self.assertEqual(account.tax_id, "") + self.assertEqual(account.balance, 0) + self.assertEqual(account.company, "Linode") + self.assertEqual(account.address_1, "3rd & Arch St") + self.assertEqual(account.address_2, "") + self.assertEqual(account.zip, "19106") + self.assertEqual(account.first_name, "Test") + self.assertEqual(account.last_name, "Guy") + self.assertEqual(account.country, "US") + self.assertIsNotNone(account.capabilities) + self.assertIsNotNone(account.active_promotions) + self.assertEqual(account.balance_uninvoiced, 145) + self.assertEqual(account.billing_source, "akamai") + self.assertEqual(account.euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71") + + def test_get_login(self): + """ + Tests that a login is loaded correctly by ID + """ + login = Login(self.client, 123) + + self.assertEqual(login.id, 123) + self.assertEqual(login.ip, "192.0.2.0") + self.assertEqual(login.restricted, True) + self.assertEqual(login.status, "successful") + self.assertEqual(login.username, "test-user") + + def test_get_account_settings(self): + """ + Tests that account settings are loaded correctly + """ + settings = AccountSettings(self.client, False, {}) + + self.assertEqual(settings.longview_subscription.id, "longview-100") + self.assertEqual(settings.managed, False) + self.assertEqual(settings.network_helper, False) + self.assertEqual(settings.object_storage, "active") + self.assertEqual(settings.backups_enabled, True) + + def test_get_event(self): + """ + Tests that an event is loaded correctly by ID + """ + 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.duration, 300.56) + self.assertIsNotNone(event.entity) + self.assertEqual(event.id, 123) + self.assertEqual(event.message, "None") + self.assertIsNone(event.percent_complete) + self.assertIsNone(event.rate) + self.assertTrue(event.read) + self.assertIsNotNone(event.secondary_entity) + self.assertTrue(event.seen) + self.assertIsNone(event.status) + self.assertIsNone(event.time_remaining) + self.assertEqual(event.username, "exampleUser") + + def test_get_invoice(self): + """ + Tests that an invoice is loaded correctly by ID + """ + invoice = Invoice(self.client, 123, {}) + + self.assertEqual(invoice.date, datetime(2018, 1, 1, 0, 1, 1)) + self.assertEqual(invoice.id, 123) + self.assertEqual(invoice.label, "Invoice") + self.assertEqual(invoice.subtotal, 120.25) + self.assertEqual(invoice.tax, 12.25) + self.assertEqual(invoice.total, 132.5) + self.assertIsNotNone(invoice.tax_summary) + + def test_get_oauth_client(self): + """ + Tests that an oauth client is loaded correctly by ID + """ + client = OAuthClient(self.client, "2737bf16b39ab5d7b4a1", {}) + + self.assertEqual(client.id, "2737bf16b39ab5d7b4a1") + self.assertEqual(client.label, "Test_Client_1") + self.assertFalse(client.public) + self.assertEqual( + client.redirect_uri, "https://example.org/oauth/callback" + ) + self.assertEqual(client.secret, "") + self.assertEqual(client.status, "active") + self.assertEqual( + client.thumbnail_url, + "https://api.linode.com/v4/account/clients/2737bf16b39ab5d7b4a1/thumbnail", + ) + + def test_get_user(self): + """ + Tests that a user is loaded correctly by username + """ + user = User(self.client, "test-user", {}) + + self.assertEqual(user.username, "test-user") + self.assertEqual(user.email, "test-user@linode.com") + self.assertTrue(user.restricted) + self.assertTrue(user.tfa_enabled) + self.assertIsNotNone(user.ssh_keys) + + def test_get_service_transfer(self): + """ + Tests that a service transfer is loaded correctly by token + """ + serviceTransfer = ServiceTransfer(self.client, "12345") + + self.assertEqual(serviceTransfer.token, "12345") + self.assertTrue(serviceTransfer.is_sender) + self.assertEqual(serviceTransfer.status, "pending") + + def test_get_payment_method(self): + """ + Tests that a payment method is loaded correctly by ID + """ + paymentMethod = PaymentMethod(self.client, 123) + + self.assertEqual(paymentMethod.id, 123) + self.assertTrue(paymentMethod.is_default) + self.assertEqual(paymentMethod.type, "credit_card") + + def test_get_user_grant(self): + """ + Tests that a user grant is loaded correctly + """ + grants = get_obj_grants() + + self.assertTrue(grants.count(("linode", Instance)) > 0) + self.assertTrue(grants.count(("domain", Domain)) > 0) + self.assertTrue(grants.count(("stackscript", StackScript)) > 0) + self.assertTrue(grants.count(("nodebalancer", NodeBalancer)) > 0) + self.assertTrue(grants.count(("volume", Volume)) > 0) + self.assertTrue(grants.count(("image", Image)) > 0) + self.assertTrue(grants.count(("longview", LongviewClient)) > 0) + self.assertTrue(grants.count(("database", Database)) > 0) + self.assertTrue(grants.count(("firewall", Firewall)) > 0) + + def test_payment_method_make_default(self): + """ + Tests that making a payment method default creates the correct api request. + """ + paymentMethod = PaymentMethod(self.client, 123) + + with self.mock_post({}) as m: + paymentMethod.payment_method_make_default() + self.assertEqual( + m.call_url, "/account/payment-methods/123/make-default" + ) + + def test_service_transfer_accept(self): + """ + Tests that accepting a service transfer creates the correct api request. + """ + serviceTransfer = ServiceTransfer(self.client, "12345") + + with self.mock_post({}) as m: + serviceTransfer.service_transfer_accept() + self.assertEqual( + m.call_url, "/account/service-transfers/12345/accept" + ) From 57ca278f9c7d4f428d9c28cfaa16aa9a4a3cfc83 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Fri, 21 Apr 2023 11:13:25 -0400 Subject: [PATCH 081/379] Added missing network-related fields and API endpoints (#239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added missing network-related fields and API endpoints to bring network to API parity. ## ✔️ How to Test Run `tox`. Ticket: TPT-1890 --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- linode_api4/linode_client.py | 61 +++++++++++++++++++ linode_api4/objects/networking.py | 21 ++++++- test/fixtures/networking_ipv6_pools.json | 13 ++++ test/fixtures/networking_ipv6_ranges.json | 13 ++++ .../networking_ipv6_ranges_2600:3c01::.json | 9 +++ test/linode_client_test.py | 53 ++++++++++++++++ test/objects/networking_test.py | 45 ++++++++++++++ 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/networking_ipv6_pools.json create mode 100644 test/fixtures/networking_ipv6_ranges.json create mode 100644 test/fixtures/networking_ipv6_ranges_2600:3c01::.json create mode 100644 test/objects/networking_test.py diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 5ad88b7a3..eac7e5a5d 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -1314,6 +1314,8 @@ def ips_assign(self, region, *assignments): :any:`IPAddress.to` for details on how to construct assignments. :type assignments: dct + + DEPRECATED: Use ip_addresses_assign() instead """ for a in assignments: if not "address" in a or not "linode_id" in a: @@ -1374,6 +1376,8 @@ def ips_share(self, linode, *ips): :type: linode: int or Instance :param ips: Any number of IPAddresses to share to the Instance. :type ips: str or IPAddress + + DEPRECATED: Use ip_addresses_share() instead """ if not isinstance(linode, Instance): # make this an object @@ -1398,6 +1402,63 @@ def ips_share(self, linode, *ips): linode.invalidate() # clear the Instance's shared IPs + def ip_addresses_share(self, ips, linode): + """ + Configure shared IPs. P sharing allows IP address reassignment + (also referred to as IP failover) from one Linode to another if the + primary Linode becomes unresponsive. This means that requests to the primary Linode’s + IP address can be automatically rerouted to secondary Linodes at the configured shared IP addresses. + + :param linode: The id of the Instance or the Instance to share the IPAddresses with. + This Instance will be able to bring up the given addresses. + :type: linode: int or Instance + :param ips: Any number of IPAddresses to share to the Instance. + :type ips: str or IPAddress + """ + + params = { + "ips": ips + if not isinstance(ips[0], IPAddress) + else [ip.address for ip in ips], + "linode_id": linode + if not isinstance(linode, Instance) + else linode.id, + } + + self.client.post("/networking/ips/share", model=self, data=params) + + def ip_addresses_assign(self, assignments, region): + """ + Assign multiple IPv4 addresses and/or IPv6 ranges to multiple Linodes in one Region. + This allows swapping, shuffling, or otherwise reorganizing IPs to your Linodes. + + The following restrictions apply: + - All Linodes involved must have at least one public IPv4 address after assignment. + - Linodes may have no more than one assigned private IPv4 address. + - Linodes may have no more than one assigned IPv6 range. + + + :param region: The Region in which the assignments should take place. + All Instances and IPAddresses involved in the assignment + must be within this region. + :type region: str or Region + :param assignments: Any number of assignments to make. See + :any:`IPAddress.to` for details on how to construct + assignments. + :type assignments: dct + """ + + for a in assignments["assignments"]: + if not "address" in a or not "linode_id" in a: + raise ValueError("Invalid assignment: {}".format(a)) + + if isinstance(region, Region): + region = region.id + + params = {"assignments": assignments, "region": region} + + self.client.post("/networking/ips/assign", model=self, data=params) + class SupportGroup(Group): """ diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index faa7e5afa..a1c2d149f 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -3,6 +3,10 @@ class IPv6Pool(Base): + """ + DEPRECATED + """ + api_endpoint = "/networking/ipv6/pools/{}" id_attribute = "range" @@ -13,16 +17,22 @@ class IPv6Pool(Base): class IPv6Range(Base): - api_endpoint = "/networking/ipv6/ranges/{}" + api_endpoint = "/networking/ipv6/ranges/{range}" id_attribute = "range" properties = { "range": Property(identifier=True), "region": Property(slug_relationship=Region, filterable=True), + "prefix": Property(), + "route_target": Property(), } class IPAddress(Base): + """ + note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + """ + api_endpoint = "/networking/ips/{address}" id_attribute = "address" @@ -56,6 +66,7 @@ def to(self, linode): if not isinstance(linode, Instance): raise ValueError("IP Address can only be assigned to a Linode!") + return {"address": self.address, "linode_id": linode.id} @@ -116,6 +127,14 @@ def update_rules(self, rules): ) self.invalidate() + def get_rules(self): + """ + Gets the JSON rules for this Firewall + """ + return self._client.get( + "{}/rules".format(self.api_endpoint), model=self + ) + def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall diff --git a/test/fixtures/networking_ipv6_pools.json b/test/fixtures/networking_ipv6_pools.json new file mode 100644 index 000000000..aef9311e4 --- /dev/null +++ b/test/fixtures/networking_ipv6_pools.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "prefix": 124, + "range": "2600:3c01::2:5000:0", + "region": "us-east", + "route_target": "2600:3c01::2:5000:f" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/networking_ipv6_ranges.json b/test/fixtures/networking_ipv6_ranges.json new file mode 100644 index 000000000..589ae42e0 --- /dev/null +++ b/test/fixtures/networking_ipv6_ranges.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "prefix": 64, + "range": "2600:3c01::", + "region": "us-east", + "route_target": "2600:3c01::ffff:ffff:ffff:ffff" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/networking_ipv6_ranges_2600:3c01::.json b/test/fixtures/networking_ipv6_ranges_2600:3c01::.json new file mode 100644 index 000000000..7e7983a12 --- /dev/null +++ b/test/fixtures/networking_ipv6_ranges_2600:3c01::.json @@ -0,0 +1,9 @@ +{ + "is_bgp": false, + "linodes": [ + 123 + ], + "prefix": 64, + "range": "2600:3c01::", + "region": "us-east" +} \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index e51d39ce0..7018ede5b 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -4,6 +4,8 @@ from unittest.mock import MagicMock from linode_api4 import ApiError, LinodeClient, LongviewSubscription +from linode_api4.objects.linode import Instance +from linode_api4.objects.networking import IPAddress class LinodeClientGeneralTest(ClientBaseCase): @@ -802,6 +804,57 @@ def test_get_firewalls(self): self.assertEqual(firewall.id, 123) + def test_ip_addresses_share(self): + """ + Tests that you can submit a correct ip addresses share api request. + """ + + ip = IPAddress(self.client, "192.0.2.1", {}) + linode = Instance(self.client, 123) + + with self.mock_post({}) as m: + self.client.networking.ip_addresses_share(["192.0.2.1"], 123) + self.assertEqual(m.call_url, "/networking/ips/share") + self.assertEqual(m.call_data["ips"], ["192.0.2.1"]) + self.assertEqual(m.call_data["linode_id"], 123) + + with self.mock_post({}) as m: + self.client.networking.ip_addresses_share([ip], 123) + self.assertEqual(m.call_url, "/networking/ips/share") + self.assertEqual(m.call_data["ips"], ["192.0.2.1"]) + self.assertEqual(m.call_data["linode_id"], 123) + + with self.mock_post({}) as m: + self.client.networking.ip_addresses_share(["192.0.2.1"], linode) + self.assertEqual(m.call_url, "/networking/ips/share") + self.assertEqual(m.call_data["ips"], ["192.0.2.1"]) + self.assertEqual(m.call_data["linode_id"], 123) + + def test_ip_addresses_assign(self): + """ + Tests that you can submit a correct ip addresses assign api request. + """ + + with self.mock_post({}) as m: + self.client.networking.ip_addresses_assign( + {"assignments": [{"address": "192.0.2.1", "linode_id": 123}]}, + "us-east", + ) + self.assertEqual(m.call_url, "/networking/ips/assign") + self.assertEqual( + m.call_data["assignments"], + {"assignments": [{"address": "192.0.2.1", "linode_id": 123}]}, + ) + self.assertEqual(m.call_data["region"], "us-east") + + def test_ipv6_ranges(self): + """ + Tests that IPRanges can be retrieved + """ + ranges = self.client.networking.ipv6_ranges() + self.assertEqual(len(ranges), 1) + self.assertEqual(ranges[0].range, "2600:3c01::") + class LinodeClientRateLimitRetryTest(TestCase): """ diff --git a/test/objects/networking_test.py b/test/objects/networking_test.py new file mode 100644 index 000000000..de04ad65b --- /dev/null +++ b/test/objects/networking_test.py @@ -0,0 +1,45 @@ +from test.base import ClientBaseCase + +from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range + + +class NetworkingTest(ClientBaseCase): + """ + Tests methods of the Networking class + """ + + def test_get_ipv6_range(self): + """ + Tests that the IPv6Range object is properly generated. + """ + + ipv6Range = IPv6Range(self.client, "2600:3c01::") + ipv6Range._api_get() + + self.assertEqual(ipv6Range.range, "2600:3c01::") + self.assertEqual(ipv6Range.prefix, 64) + self.assertEqual(ipv6Range.region.id, "us-east") + + ranges = self.client.networking.ipv6_ranges() + + self.assertEqual(ranges[0].range, "2600:3c01::") + self.assertEqual(ranges[0].prefix, 64) + self.assertEqual(ranges[0].region.id, "us-east") + self.assertEqual( + ranges[0].route_target, "2600:3c01::ffff:ffff:ffff:ffff" + ) + + def test_get_rules(self): + """ + Tests that you can submit a correct firewall rules view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/rules") as m: + result = firewall.get_rules() + self.assertEqual(m.call_url, "/networking/firewalls/123/rules") + self.assertEqual(result["inbound"], []) + self.assertEqual(result["outbound"], []) + self.assertEqual(result["inbound_policy"], "DROP") + self.assertEqual(result["outbound_policy"], "DROP") From 975438aa8fe55745a625c8000a2a349660d798f9 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 21 Apr 2023 12:11:48 -0400 Subject: [PATCH 082/379] proposal: Move all top-level methods to groups and correct documentation (#252) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change moves all top-level methods (`LinodeClient.nodebalancers(...)`, etc.) to groups to ensure consistency with the rest of the project. Additionally, alias methods have been created to prevent introducing any breaking changes and to ensure backwards compatibility. Top-level list methods are now implemented as `__call__(...)` methods for group classes, and create functions have been moved to `create(...)` methods inside of each group. These groups have all been moved to a separate `groups` package which significantly reduces the size of the `linode_client` package. This change also introduces a few fixes for documentation issues: - The sidebar width has been slightly increased to prevent content on the sidebar from overlapping with page content - The page width has been slightly increased to improve readability - Groups that were previously missing from the documentation have since been added ## ✔️ How to Test Testing refactor changes: ```bash tox ``` Testing docs changes: ```bash sphinx-autobuild docs docs/build ``` --- docs/conf.py | 7 +- docs/linode_api4/linode_client.rst | 103 +- linode_api4/groups/__init__.py | 18 + linode_api4/groups/account.py | 444 ++++++ linode_api4/groups/database.py | 266 ++++ linode_api4/groups/domain.py | 59 + linode_api4/groups/group.py | 3 + linode_api4/groups/image.py | 130 ++ linode_api4/groups/linode.py | 365 +++++ linode_api4/groups/lke.py | 136 ++ linode_api4/groups/longview.py | 62 + linode_api4/groups/networking.py | 330 ++++ linode_api4/groups/nodebalancer.py | 50 + linode_api4/groups/obj.py | 156 ++ linode_api4/groups/profile.py | 159 ++ linode_api4/groups/region.py | 23 + linode_api4/groups/support.py | 103 ++ linode_api4/groups/tag.py | 114 ++ linode_api4/groups/volume.py | 71 + linode_api4/linode_client.py | 2336 +--------------------------- 20 files changed, 2633 insertions(+), 2302 deletions(-) create mode 100644 linode_api4/groups/__init__.py create mode 100644 linode_api4/groups/account.py create mode 100644 linode_api4/groups/database.py create mode 100644 linode_api4/groups/domain.py create mode 100644 linode_api4/groups/group.py create mode 100644 linode_api4/groups/image.py create mode 100644 linode_api4/groups/linode.py create mode 100644 linode_api4/groups/lke.py create mode 100644 linode_api4/groups/longview.py create mode 100644 linode_api4/groups/networking.py create mode 100644 linode_api4/groups/nodebalancer.py create mode 100644 linode_api4/groups/obj.py create mode 100644 linode_api4/groups/profile.py create mode 100644 linode_api4/groups/region.py create mode 100644 linode_api4/groups/support.py create mode 100644 linode_api4/groups/tag.py create mode 100644 linode_api4/groups/volume.py diff --git a/docs/conf.py b/docs/conf.py index a3f980a52..cd1654ef0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'linode_api4' -copyright = '2020, Linode' +copyright = '2023, Linode' author = 'Linode' # The short X.Y version @@ -86,7 +86,10 @@ # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = { + 'sidebar_width': '320px', + 'page_width': '1000px', +} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index b7e5ec65d..172e00864 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -52,6 +52,42 @@ example:: See :any:`LinodeClient` for more information on the naming of these groups, although generally they are named the same as the first word of the group. +AccountGroup +^^^^^^^^^^^^ + +Includes methods for managing your account. + +.. autoclass:: linode_api4.linode_client.AccountGroup + :members: + :special-members: + +DatabaseGroup +^^^^^^^^^^^^^ + +Includes methods for managing Linode Managed Databases. + +.. autoclass:: linode_api4.linode_client.DatabaseGroup + :members: + :special-members: + +DomainGroup +^^^^^^^^^^^ + +Includes methods for managing Linode Domains. + +.. autoclass:: linode_api4.linode_client.DomainGroup + :members: + :special-members: + +ImageGroup +^^^^^^^^^^ + +Includes methods for managing Linode Images. + +.. autoclass:: linode_api4.linode_client.ImageGroup + :members: + :special-members: + LinodeGroup ^^^^^^^^^^^ @@ -60,22 +96,25 @@ accessing and working with associated features. .. autoclass:: linode_api4.linode_client.LinodeGroup :members: + :special-members: -AccountGroup -^^^^^^^^^^^^ +LKE Group +^^^^^^^^^ -Includes methods for managing your account. +Includes methods for interacting with Linode Kubernetes Engine. -.. autoclass:: linode_api4.linode_client.AccountGroup +.. autoclass:: linode_api4.linode_client.LKEGroup :members: + :special-members: -ProfileGroup -^^^^^^^^^^^^ +LongviewGroup +^^^^^^^^^^^^^ -Includes methods for managing your user. +Includes methods for interacting with our Longview service. -.. autoclass:: linode_api4.linode_client.ProfileGroup +.. autoclass:: linode_api4.linode_client.LongviewGroup :members: + :special-members: NetworkingGroup ^^^^^^^^^^^^^^^ @@ -84,14 +123,16 @@ Includes methods for managing your networking systems. .. autoclass:: linode_api4.linode_client.NetworkingGroup :members: + :special-members: -LongviewGroup -^^^^^^^^^^^^^ +NodeBalancerGroup +^^^^^^^^^^^^^^^^^ -Includes methods for interacting with our Longview service. +Includes methods for managing Linode NodeBalancers. -.. autoclass:: linode_api4.linode_client.LongviewGroup +.. autoclass:: linode_api4.linode_client.NodeBalancerGroup :members: + :special-members: ObjectStorageGroup ^^^^^^^^^^^^^^^^^^ @@ -101,16 +142,27 @@ with buckets and objects, use the s3 API directly with a library like `boto3`_. .. autoclass:: linode_api4.linode_client.ObjectStorageGroup :members: + :special-members: .. _boto3: https://github.com/boto/boto3 -LKE Group -^^^^^^^^^ +ProfileGroup +^^^^^^^^^^^^ -Includes methods for interacting with Linode Kubernetes Engine. +Includes methods for managing your user. -.. autoclass:: linode_api4.linode_client.LKEGroup +.. autoclass:: linode_api4.linode_client.ProfileGroup + :members: + :special-members: + +RegionGroup +^^^^^^^^^^^ + +Includes methods for accessing information about Linode Regions. + +.. autoclass:: linode_api4.linode_client.RegionGroup :members: + :special-members: SupportGroup ^^^^^^^^^^^^ @@ -119,3 +171,22 @@ Includes methods for viewing and opening tickets with our support department. .. autoclass:: linode_api4.linode_client.SupportGroup :members: + :special-members: + +TagGroup +^^^^^^^^ + +Includes methods for managing Linode Tags. + +.. autoclass:: linode_api4.linode_client.TagGroup + :members: + :special-members: + +VolumeGroup +^^^^^^^^^^^ + +Includes methods for managing Linode Volumes. + +.. autoclass:: linode_api4.linode_client.VolumeGroup + :members: + :special-members: diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py new file mode 100644 index 000000000..74419a606 --- /dev/null +++ b/linode_api4/groups/__init__.py @@ -0,0 +1,18 @@ +# Group needs to be imported first +from .group import * # isort: skip + +from .account import * +from .database import * +from .domain import * +from .image import * +from .linode import * +from .lke import * +from .longview import * +from .networking import * +from .nodebalancer import * +from .obj import * +from .profile import * +from .region import * +from .support import * +from .tag import * +from .volume import * diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py new file mode 100644 index 000000000..d630939e0 --- /dev/null +++ b/linode_api4/groups/account.py @@ -0,0 +1,444 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + Account, + AccountSettings, + Event, + Invoice, + Login, + MappedObject, + OAuthClient, + Payment, + PaymentMethod, + ServiceTransfer, + User, +) + + +class AccountGroup(Group): + """ + Collections related to your account. + """ + + def __call__(self): + """ + Retrieves information about the acting user's account, such as billing + information. This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + account = client.account() + + API Documentation: https://www.linode.com/docs/api/account/#account-view + + :returns: Returns the acting user's account information. + :rtype: Account + """ + result = self.client.get("/account") + + if not "email" in result: + raise UnexpectedResponseError( + "Unexpected response when getting account!", json=result + ) + + return Account(self.client, result["email"], result) + + def events(self, *filters): + """ + Lists events on the current account matching the given filters. + + API Documentation: https://www.linode.com/docs/api/account/#events-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of events on the current account matching the given filters. + :rtype: PaginatedList of Event + """ + + return self.client._get_and_filter(Event, *filters) + + def events_mark_seen(self, event): + """ + Marks event as the last event we have seen. If event is an int, it is treated + as an event_id, otherwise it should be an event object whose id will be used. + + API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen + + :param event: The Linode event to mark as seen. + :type event: Event or int + """ + last_seen = event if isinstance(event, int) else event.id + self.client.post( + "{}/seen".format(Event.api_endpoint), + model=Event(self.client, last_seen), + ) + + def settings(self): + """ + Returns the account settings data for this acocunt. This is not a + listing endpoint. + + API Documentation: https://www.linode.com/docs/api/account/#account-settings-view + + :returns: The account settings data for this account. + :rtype: AccountSettings + """ + result = self.client.get("/account/settings") + + if not "managed" in result: + raise UnexpectedResponseError( + "Unexpected response when getting account settings!", + json=result, + ) + + s = AccountSettings(self.client, result["managed"], result) + return s + + def invoices(self): + """ + Returns Invoices issued to this account. + + API Documentation: https://www.linode.com/docs/api/account/#invoices-list + + :param filters: Any number of filters to apply to this query. + + :returns: Invoices issued to this account. + :rtype: PaginatedList of Invoice + """ + return self.client._get_and_filter(Invoice) + + def payments(self): + """ + Returns a list of Payments made on this account. + + API Documentation: https://www.linode.com/docs/api/account/#payments-list + + :returns: A list of payments made on this account. + :rtype: PaginatedList of Payment + """ + return self.client._get_and_filter(Payment) + + def oauth_clients(self, *filters): + """ + Returns the OAuth Clients associated with this account. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of OAuth Clients associated with this account. + :rtype: PaginatedList of OAuthClient + """ + return self.client._get_and_filter(OAuthClient, *filters) + + def oauth_client_create(self, name, redirect_uri, **kwargs): + """ + Creates a new OAuth client. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-client-create + + :param name: The name of this application. + :type name: str + :param redirect_uri: The location a successful log in from https://login.linode.com should be redirected to for this client. + :type redirect_uri: str + + :returns: The created OAuth Client. + :rtype: OAuthClient + """ + params = { + "label": name, + "redirect_uri": redirect_uri, + } + params.update(kwargs) + + result = self.client.post("/account/oauth-clients", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating OAuth Client!", json=result + ) + + c = OAuthClient(self.client, result["id"], result) + return c + + def users(self, *filters): + """ + Returns a list of users on this account. + + API Documentation: https://www.linode.com/docs/api/account/#users-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of users on this account. + :rtype: PaginatedList of User + """ + return self.client._get_and_filter(User, *filters) + + def logins(self): + """ + Returns a collection of successful logins for all users on the account during the last 90 days. + + API Documentation: https://www.linode.com/docs/api/account/#user-logins-list-all + + :returns: A list of Logins on this account. + :rtype: PaginatedList of Login + """ + + return self.client._get_and_filter(Login) + + 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://www.linode.com/docs/api/account/#user-logins-list-all + + :returns: A list of Maintenance objects on this account. + :rtype: List of Maintenance objects as MappedObjects + """ + + result = self.client.get( + "{}/maintenance".format(Account.api_endpoint), model=self + ) + + return [MappedObject(**r) for r in result["data"]] + + def payment_methods(self): + """ + Returns a list of Payment Methods for this Account. + + API Documentation: https://www.linode.com/docs/api/account/#payment-methods-list + + :returns: A list of Payment Methods on this account. + :rtype: PaginatedList of PaymentMethod + """ + + return self.client._get_and_filter(PaymentMethod) + + def add_payment_method(self, data, is_default, type): + """ + Adds a Payment Method to your Account with the option to set it as the default method. + + API Documentation: https://www.linode.com/docs/api/account/#payment-method-add + + :param data: An object representing the credit card information you have on file with + Linode to make Payments against your Account. + :type data: dict + + Example usage:: + data = { + "card_number": "4111111111111111", + "expiry_month": 11, + "expiry_year": 2020, + "cvv": "111" + } + + :param is_default: Whether this Payment Method is the default method for + automatically processing service charges. + :type is_default: bool + + :param type: The type of Payment Method. Enum: ["credit_card] + :type type: str + """ + + if type != "credit_card": + raise ValueError("Unknown Payment Method type: {}".format(type)) + + if ( + "card_number" not in data + or "expiry_month" not in data + or "expiry_year" not in data + or "cvv" not in data + or not data + ): + raise ValueError("Invalid credit card info provided") + + params = {"data": data, "type": type, "is_default": is_default} + + resp = self.client.post( + "{}/payment-methods".format(Account.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when adding payment method!", + json=resp, + ) + + def notifications(self): + """ + Returns a collection of Notification objects representing important, often time-sensitive items related to your Account. + + API Documentation: https://www.linode.com/docs/api/account/#notifications-list + + :returns: A list of Notifications on this account. + :rtype: List of Notification objects as MappedObjects + """ + + result = self.client.get( + "{}/notifications".format(Account.api_endpoint), model=self + ) + + return [MappedObject(**r) for r in result["data"]] + + def linode_managed_enable(self): + """ + Enables Linode Managed for the entire account and sends a welcome email to the account’s associated email address. + + API Documentation: https://www.linode.com/docs/api/account/#linode-managed-enable + """ + + resp = self.client.post( + "{}/settings/managed-enable".format(Account.api_endpoint), + model=self, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when enabling Linode Managed!", + json=resp, + ) + + def add_promo_code(self, promo_code): + """ + Adds an expiring Promo Credit to your account. + + API Documentation: https://www.linode.com/docs/api/account/#promo-credit-add + + :param promo_code: The Promo Code. + :type promo_code: str + """ + + params = { + "promo_code": promo_code, + } + + resp = self.client.post( + "{}/promo-codes".format(Account.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when adding Promo Code!", + json=resp, + ) + + def service_transfers(self): + """ + Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer. + + API Documentation: https://www.linode.com/docs/api/account/#service-transfers-list + + :returns: A list of Service Transfers on this account. + :rtype: PaginatedList of ServiceTransfer + """ + + return self.client._get_and_filter(ServiceTransfer) + + def service_transfer_create(self, entities): + """ + Creates a transfer request for the specified services. + + API Documentation: https://www.linode.com/docs/api/account/#service-transfer-create + + :param entities: A collection of the services to include in this transfer request, separated by type. + :type entities: dict + + Example usage:: + entities = { + "linodes": [ + 111, + 222 + ] + } + """ + + if not entities: + raise ValueError("Entities must be provided!") + + bad_entries = [ + k for k, v in entities.items() if not isinstance(v, list) + ] + if len(bad_entries) > 0: + raise ValueError( + f"Got unexpected type for entity lists: {', '.join(bad_entries)}" + ) + + params = {"entities": entities} + + resp = self.client.post( + "{}/service-transfers".format(Account.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when creating Service Transfer!", + json=resp, + ) + + def transfer(self): + """ + Returns a MappedObject containing the account's transfer pool data. + + API Documentation: https://www.linode.com/docs/api/account/#network-utilization-view + + :returns: Information about this account's transfer pool data. + :rtype: MappedObject + """ + result = self.client.get("/account/transfer") + + if not "used" in result: + raise UnexpectedResponseError( + "Unexpected response when getting Transfer Pool!" + ) + + return MappedObject(**result) + + def user_create(self, email, username, restricted=True): + """ + Creates a new user on your account. If you create an unrestricted user, + they will immediately be able to access everything on your account. If + you create a restricted user, you must grant them access to parts of your + account that you want to allow them to manage (see :any:`User.grants` for + details). + + The new user will receive an email inviting them to set up their password. + This must be completed before they can log in. + + API Documentation: https://www.linode.com/docs/api/account/#user-create + + :param email: The new user's email address. This is used to finish setting + up their user account. + :type email: str + :param username: The new user's unique username. They will use this username + to log in. + :type username: str + :param restricted: If True, the new user must be granted access to parts of + the account before they can do anything. If False, the + new user will immediately be able to manage the entire + account. Defaults to True. + :type restricted: True + + :returns The new User. + :rtype: User + """ + params = { + "email": email, + "username": username, + "restricted": restricted, + } + result = self.client.post("/account/users", data=params) + + if not all( + [c in result for c in ("email", "restricted", "username")] + ): # pylint: disable=use-a-generator + raise UnexpectedResponseError( + "Unexpected response when creating user!", json=result + ) + + u = User(self.client, result["username"], result) + return u diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py new file mode 100644 index 000000000..494c30eba --- /dev/null +++ b/linode_api4/groups/database.py @@ -0,0 +1,266 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + Base, + Database, + DatabaseEngine, + DatabaseType, + MongoDBDatabase, + MySQLDatabase, + PostgreSQLDatabase, +) + + +class DatabaseGroup(Group): + """ + Encapsulates Linode Managed Databases related methods of the :any:`LinodeClient`. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.database.instances() # use the DatabaseGroup + + This group contains all features beneath the `/databases` group in the API v4. + """ + + def types(self, *filters): + """ + Returns a list of Linode Database-compatible Instance types. + These may be used to create Managed Databases, or simply + referenced to on their own. DatabaseTypes can be + filtered to return specific types, for example:: + + database_types = client.database.types(DatabaseType.deprecated == False) + + API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list + + :param filters: Any number of filters to apply to the query. + + :returns: A list of types that match the query. + :rtype: PaginatedList of DatabaseType + """ + return self.client._get_and_filter(DatabaseType, *filters) + + def engines(self, *filters): + """ + Returns a list of Linode Managed Database Engines. + These may be used to create Managed Databases, or simply + referenced to on their own. Engines can be filtered to + return specific engines, for example:: + + mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql') + + API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list + + :param filters: Any number of filters to apply to the query. + + :returns: A list of types that match the query. + :rtype: PaginatedList of DatabaseEngine + """ + return self.client._get_and_filter(DatabaseEngine, *filters) + + def instances(self, *filters): + """ + Returns a list of Managed Databases active on this account. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all + + :param filters: Any number of filters to apply to this query. + + :returns: A list of databases that matched the query. + :rtype: PaginatedList of Database + """ + return self.client._get_and_filter(Database, *filters) + + def mysql_instances(self, *filters): + """ + Returns a list of Managed MySQL Databases active on this account. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of MySQL databases that matched the query. + :rtype: PaginatedList of MySQLDatabase + """ + return self.client._get_and_filter(MySQLDatabase, *filters) + + def mysql_create(self, label, region, engine, ltype, **kwargs): + """ + Creates an :any:`MySQLDatabase` on this account with + the given label, region, engine, and node type. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + region = client.regions().first() + node_type = client.database.types()[0] + engine = client.database.engines(DatabaseEngine.engine == 'mysql')[0] + + new_database = client.database.mysql_create( + "example-database", + region, + engine.id, + type.id + ) + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-create + + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str or Region + :param engine: The engine to deploy this cluster with + :type engine: str or Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str or Type + """ + + params = { + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "engine": engine.id if issubclass(type(engine), Base) else engine, + "type": ltype.id if issubclass(type(ltype), Base) else ltype, + } + params.update(kwargs) + + result = self.client.post("/databases/mysql/instances", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating MySQL Database", json=result + ) + + d = MySQLDatabase(self.client, result["id"], result) + return d + + def postgresql_instances(self, *filters): + """ + Returns a list of Managed PostgreSQL Databases active on this account. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of PostgreSQL databases that matched the query. + :rtype: PaginatedList of PostgreSQLDatabase + """ + return self.client._get_and_filter(PostgreSQLDatabase, *filters) + + def postgresql_create(self, label, region, engine, ltype, **kwargs): + """ + Creates an :any:`PostgreSQLDatabase` on this account with + the given label, region, engine, and node type. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + region = client.regions().first() + node_type = client.database.types()[0] + engine = client.database.engines(DatabaseEngine.engine == 'postgresql')[0] + + new_database = client.database.postgresql_create( + "example-database", + region, + engine.id, + type.id + ) + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-create + + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str or Region + :param engine: The engine to deploy this cluster with + :type engine: str or Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str or Type + """ + + params = { + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "engine": engine.id if issubclass(type(engine), Base) else engine, + "type": ltype.id if issubclass(type(ltype), Base) else ltype, + } + params.update(kwargs) + + result = self.client.post( + "/databases/postgresql/instances", data=params + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating PostgreSQL Database", + json=result, + ) + + d = PostgreSQLDatabase(self.client, result["id"], result) + return d + + def mongodb_instances(self, *filters): + """ + Returns a list of Managed MongoDB Databases active on this account. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-databases-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of MongoDB databases that matched the query. + :rtype: PaginatedList of MongoDBDatabase + """ + return self.client._get_and_filter(MongoDBDatabase, *filters) + + def mongodb_create(self, label, region, engine, ltype, **kwargs): + """ + Creates an :any:`MongoDBDatabase` on this account with + the given label, region, engine, and node type. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + region = client.regions().first() + node_type = client.database.types()[0] + engine = client.database.engines(DatabaseEngine.engine == 'mongodb')[0] + + new_database = client.database.mongodb_create( + "example-database", + region, + engine.id, + type.id + ) + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-create + + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str or Region + :param engine: The engine to deploy this cluster with + :type engine: str or Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str or Type + """ + + params = { + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "engine": engine.id if issubclass(type(engine), Base) else engine, + "type": ltype.id if issubclass(type(ltype), Base) else ltype, + } + params.update(kwargs) + + result = self.client.post("/databases/mongodb/instances", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating MongoDB Database", + json=result, + ) + + d = MongoDBDatabase(self.client, result["id"], result) + return d diff --git a/linode_api4/groups/domain.py b/linode_api4/groups/domain.py new file mode 100644 index 000000000..731bc3fb4 --- /dev/null +++ b/linode_api4/groups/domain.py @@ -0,0 +1,59 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Domain + + +class DomainGroup(Group): + def __call__(self, *filters): + """ + Retrieves all of the Domains the acting user has access to. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + domains = client.domains() + + API Documentation: https://www.linode.com/docs/api/domains/#domains-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Domains the acting user can access. + :rtype: PaginatedList of Domain + """ + return self.client._get_and_filter(Domain, *filters) + + def create(self, domain, master=True, **kwargs): + """ + Registers a new Domain on the acting user's account. Make sure to point + your registrar to Linode's nameservers so that Linode's DNS manager will + correctly serve your domain. + + API Documentation: https://www.linode.com/docs/api/domains/#domain-create + + :param domain: The domain to register to Linode's DNS manager. + :type domain: str + :param master: Whether this is a master (defaults to true) + :type master: bool + :param tags: A list of tags to apply to the new domain. If any of the + tags included do not exist, they will be created as part of + this operation. + :type tags: list[str] + + :returns: The new Domain object. + :rtype: Domain + """ + params = { + "domain": domain, + "type": "master" if master else "slave", + } + params.update(kwargs) + + result = self.client.post("/domains", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Domain!", json=result + ) + + d = Domain(self.client, result["id"], result) + return d diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py new file mode 100644 index 000000000..1ca41627a --- /dev/null +++ b/linode_api4/groups/group.py @@ -0,0 +1,3 @@ +class Group: + def __init__(self, client: "LinodeClient"): + self.client = client diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py new file mode 100644 index 000000000..c1d6f998e --- /dev/null +++ b/linode_api4/groups/image.py @@ -0,0 +1,130 @@ +from typing import BinaryIO, Tuple + +import requests + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Base, Image +from linode_api4.util import drop_null_keys + + +class ImageGroup(Group): + def __call__(self, *filters): + """ + Retrieves a list of available Images, including public and private + Images available to the acting user. You can filter this query to + retrieve only Images relevant to a specific query, for example:: + + debian_images = client.images( + Image.vendor == "debain") + + API Documentation: https://www.linode.com/docs/api/images/#images-list + + :param filters: Any number of filters to apply to the query. + + :returns: A list of available Images. + :rtype: PaginatedList of Image + """ + return self.client._get_and_filter(Image, *filters) + + def create(self, disk, label=None, description=None): + """ + Creates a new Image from a disk you own. + + API Documentation: https://www.linode.com/docs/api/images/#image-create + + :param disk: The Disk to imagize. + :type disk: Disk or int + :param label: The label for the resulting Image (defaults to the disk's + label. + :type label: str + :param description: The description for the new Image. + :type description: str + + :returns: The new Image. + :rtype: Image + """ + params = { + "disk_id": disk.id if issubclass(type(disk), Base) else disk, + } + + if label is not None: + params["label"] = label + + if description is not None: + params["description"] = description + + result = self.client.post("/images", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating an Image from disk {}".format( + disk + ) + ) + + return Image(self.client, result["id"], result) + + def create_upload( + self, label: str, region: str, description: str = None + ) -> Tuple[Image, str]: + """ + Creates a new Image and returns the corresponding upload URL. + + API Documentation: https://www.linode.com/docs/api/images/#image-upload + + :param label: The label of the Image to create. + :type label: str + :param region: The region to upload to. Once the image has been created, it can be used in any region. + :type region: str + :param description: The description for the new Image. + :type description: str + + :returns: A tuple containing the new image and the image upload URL. + :rtype: (Image, str) + """ + params = {"label": label, "region": region, "description": description} + + result = self.client.post("/images/upload", data=drop_null_keys(params)) + + if "image" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating an Image upload URL" + ) + + result_image = result["image"] + result_url = result["upload_to"] + + return Image(self.client, result_image["id"], result_image), result_url + + def upload( + self, label: str, region: str, file: BinaryIO, description: str = None + ) -> Image: + """ + Creates and uploads a new image. + + API Documentation: https://www.linode.com/docs/api/images/#image-upload + + :param label: The label of the Image to create. + :type label: str + :param region: The region to upload to. Once the image has been created, it can be used in any region. + :type region: str + :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb"). + :param description: The description for the new Image. + :type description: str + + :returns: The resulting image. + :rtype: Image + """ + + image, url = self.create_upload(label, region, description=description) + + requests.put( + url, + headers={"Content-Type": "application/octet-stream"}, + data=file, + ) + + image._api_get() + + return image diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py new file mode 100644 index 000000000..3c4112d29 --- /dev/null +++ b/linode_api4/groups/linode.py @@ -0,0 +1,365 @@ +import os + +from linode_api4 import Profile +from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + AuthorizedApp, + Base, + Image, + Instance, + Kernel, + PersonalAccessToken, + SSHKey, + StackScript, + Type, +) +from linode_api4.objects.filtering import Filter +from linode_api4.paginated_list import PaginatedList + + +class LinodeGroup(Group): + """ + Encapsulates Linode-related methods of the :any:`LinodeClient`. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.linode.instances() # use the LinodeGroup + + This group contains all features beneath the `/linode` group in the API v4. + """ + + def types(self, *filters): + """ + Returns a list of Linode Instance types. These may be used to create + or resize Linodes, or simply referenced on their own. Types can be + filtered to return specific types, for example:: + + standard_types = client.linode.types(Type.class == "standard") + + API documentation: https://www.linode.com/docs/api/linode-types/#types-list + + :param filters: Any number of filters to apply to the query. + + :returns: A list of types that match the query. + :rtype: PaginatedList of Type + """ + return self.client._get_and_filter(Type, *filters) + + def instances(self, *filters): + """ + Returns a list of Linode Instances on your account. You may filter + this query to return only Linodes that match specific criteria:: + + prod_linodes = client.linode.instances(Instance.group == "prod") + + API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Instances that matched the query. + :rtype: PaginatedList of Instance + """ + return self.client._get_and_filter(Instance, *filters) + + def stackscripts(self, *filters, **kwargs): + """ + Returns a list of :any:`StackScripts`, both public and + private. You may filter this query to return only + :any:`StackScripts` that match certain criteria. You may + also request only your own private :any:`StackScripts`:: + + my_stackscripts = client.linode.stackscripts(mine_only=True) + + API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list + + :param filters: Any number of filters to apply to this query. + :param mine_only: If True, returns only private StackScripts + :type mine_only: bool + + :returns: A list of StackScripts matching the query. + :rtype: PaginatedList of StackScript + """ + # python2 can't handle *args and a single keyword argument, so this is a workaround + if "mine_only" in kwargs: + if kwargs["mine_only"]: + new_filter = Filter({"mine": True}) + if filters: + filters = list(filters) + filters[0] = filters[0] & new_filter + else: + filters = [new_filter] + + del kwargs["mine_only"] + + if kwargs: + raise TypeError( + "stackscripts() got unexpected keyword argument '{}'".format( + kwargs.popitem()[0] + ) + ) + + return self.client._get_and_filter(StackScript, *filters) + + def kernels(self, *filters): + """ + Returns a list of available :any:`Kernels`. Kernels are used + when creating or updating :any:`LinodeConfigs,LinodeConfig>`. + + API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of available kernels that match the query. + :rtype: PaginatedList of Kernel + """ + return self.client._get_and_filter(Kernel, *filters) + + # create things + def instance_create( + self, ltype, region, image=None, authorized_keys=None, **kwargs + ): + """ + Creates a new Linode Instance. This function has several modes of operation: + + **Create an Instance from an Image** + + To create an Instance from an :any:`Image`, call `instance_create` with + a :any:`Type`, a :any:`Region`, and an :any:`Image`. All three of + these fields may be provided as either the ID or the appropriate object. + In this mode, a root password will be generated and returned with the + new Instance object. For example:: + + new_linode, password = client.linode.instance_create( + "g6-standard-2", + "us-east", + image="linode/debian9") + + ltype = client.linode.types().first() + region = client.regions().first() + image = client.images().first() + + another_linode, password = client.linode.instance_create( + ltype, + region, + image=image) + + **Create an Instance from StackScript** + + When creating an Instance from a :any:`StackScript`, an :any:`Image` that + the StackScript support must be provided.. You must also provide any + required StackScript data for the script's User Defined Fields.. For + example, if deploying `StackScript 10079`_ (which deploys a new Instance + with a user created from keys on `github`_:: + + stackscript = StackScript(client, 10079) + + new_linode, password = client.linode.instance_create( + "g6-standard-2", + "us-east", + image="linode/debian9", + stackscript=stackscript, + stackscript_data={"gh_username": "example"}) + + In the above example, "gh_username" is the name of a User Defined Field + in the chosen StackScript. For more information on StackScripts, see + the `StackScript guide`_. + + .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079 + .. _`github`: https://github.com + .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/ + + **Create an Instance from a Backup** + + To create a new Instance by restoring a :any:`Backup` to it, provide a + :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore. You + may provide either IDs or objects for all of these fields:: + + existing_linode = Instance(client, 123) + snapshot = existing_linode.available_backups.snapshot.current + + new_linode = client.linode.instance_create( + "g6-standard-2", + "us-east", + backup=snapshot) + + **Create an empty Instance** + + If you want to create an empty Instance that you will configure manually, + simply call `instance_create` with a :any:`Type` and a :any:`Region`:: + + empty_linode = client.linode.instance_create("g6-standard-2", "us-east") + + When created this way, the Instance will not be booted and cannot boot + successfully until disks and configs are created, or it is otherwise + configured. + + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-create + + :param ltype: The Instance Type we are creating + :type ltype: str or Type + :param region: The Region in which we are creating the Instance + :type region: str or Region + :param image: The Image to deploy to this Instance. If this is provided + and no root_pass is given, a password will be generated + and returned along with the new Instance. + :type image: str or Image + :param stackscript: The StackScript to deploy to the new Instance. If + provided, "image" is required and must be compatible + with the chosen StackScript. + :type stackscript: int or StackScript + :param stackscript_data: Values for the User Defined Fields defined in + the chosen StackScript. Does nothing if + StackScript is not provided. + :type stackscript_data: dict + :param backup: The Backup to restore to the new Instance. May not be + provided if "image" is given. + :type backup: int of Backup + :param authorized_keys: The ssh public keys to install in the linode's + /root/.ssh/authorized_keys file. Each entry may + be a single key, or a path to a file containing + the key. + :type authorized_keys: list or str + :param label: The display label for the new Instance + :type label: str + :param group: The display group for the new Instance + :type group: str + :param booted: Whether the new Instance should be booted. This will + default to True if the Instance is deployed from an Image + or Backup. + :type booted: bool + :param tags: A list of tags to apply to the new instance. If any of the + tags included do not exist, they will be created as part of + this operation. + :type tags: list[str] + :param private_ip: Whether the new Instance should have private networking + enabled and assigned a private IPv4 address. + :type private_ip: bool + + :returns: A new Instance object, or a tuple containing the new Instance and + the generated password. + :rtype: Instance or tuple(Instance, str) + :raises ApiError: If contacting the API fails + :raises UnexpectedResponseError: If the API response is somehow malformed. + This usually indicates that you are using + an outdated library. + """ + ret_pass = None + if image and not "root_pass" in kwargs: + ret_pass = Instance.generate_root_password() + kwargs["root_pass"] = ret_pass + + authorized_keys = load_and_validate_keys(authorized_keys) + + if "stackscript" in kwargs: + # translate stackscripts + kwargs["stackscript_id"] = ( + kwargs["stackscript"].id + if issubclass(type(kwargs["stackscript"]), Base) + else kwargs["stackscript"] + ) + del kwargs["stackscript"] + + if "backup" in kwargs: + # translate backups + kwargs["backup_id"] = ( + kwargs["backup"].id + if issubclass(type(kwargs["backup"]), Base) + else kwargs["backup"] + ) + del kwargs["backup"] + + params = { + "type": ltype.id if issubclass(type(ltype), Base) else ltype, + "region": region.id if issubclass(type(region), Base) else region, + "image": (image.id if issubclass(type(image), Base) else image) + if image + else None, + "authorized_keys": authorized_keys, + } + params.update(kwargs) + + result = self.client.post("/linode/instances", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating linode!", json=result + ) + + l = Instance(self.client, result["id"], result) + if not ret_pass: + return l + return l, ret_pass + + def stackscript_create( + self, label, script, images, desc=None, public=False, **kwargs + ): + """ + Creates a new :any:`StackScript` on your account. + + API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-create + + :param label: The label for this StackScript. + :type label: str + :param script: The script to run when an :any:`Instance` is deployed with + this StackScript. Must begin with a shebang (#!). + :type script: str + :param images: A list of :any:`Images` that this StackScript + supports. Instances will not be deployed from this + StackScript unless deployed from one of these Images. + :type images: list of Image + :param desc: A description for this StackScript. + :type desc: str + :param public: Whether this StackScript is public. Defaults to False. + Once a StackScript is made public, it may not be set + back to private. + :type public: bool + + :returns: The new StackScript + :rtype: StackScript + """ + image_list = None + if type(images) is list or type(images) is PaginatedList: + image_list = [ + d.id if issubclass(type(d), Base) else d for d in images + ] + elif type(images) is Image: + image_list = [images.id] + elif type(images) is str: + image_list = [images] + else: + raise ValueError( + "images must be a list of Images or a single Image" + ) + + script_body = script + if not script.startswith("#!"): + # it doesn't look like a stackscript body, let's see if it's a file + if os.path.isfile(script): + with open(script) as f: + script_body = f.read() + else: + raise ValueError( + "script must be the script text or a path to a file" + ) + + params = { + "label": label, + "images": image_list, + "is_public": public, + "script": script_body, + "description": desc if desc else "", + } + params.update(kwargs) + + result = self.client.post("/linode/stackscripts", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating StackScript!", json=result + ) + + s = StackScript(self.client, result["id"], result) + return s diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py new file mode 100644 index 000000000..dfd4c6d7a --- /dev/null +++ b/linode_api4/groups/lke.py @@ -0,0 +1,136 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Base, KubeVersion, LKECluster + + +class LKEGroup(Group): + """ + Encapsulates LKE-related methods of the :any:`LinodeClient`. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.lke.clusters() # use the LKEGroup + + This group contains all features beneath the `/lke` group in the API v4. + """ + + def versions(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be + used when creating an LKE Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list + + :param filters: Any number of filters to apply to the query. + + :returns: A Paginated List of kube versions that match the query. + :rtype: PaginatedList of KubeVersion + """ + return self.client._get_and_filter(KubeVersion, *filters) + + def clusters(self, *filters): + """ + Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong + to this account. + + https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list + + :param filters: Any number of filters to apply to the query. + + :returns: A Paginated List of LKE clusters that match the query. + :rtype: PaginatedList of LKECluster + """ + return self.client._get_and_filter(LKECluster, *filters) + + def cluster_create(self, region, label, node_pools, kube_version, **kwargs): + """ + Creates an :any:`LKECluster` on this account in the given region, with + the given label, and with node pools as described. For example:: + + client = LinodeClient(TOKEN) + + # look up Region and Types to use. In this example I'm just using + # the first ones returned. + target_region = client.regions().first() + node_type = client.linode.types()[0] + node_type_2 = client.linode.types()[1] + kube_version = client.lke.versions()[0] + + new_cluster = client.lke.cluster_create( + target_region, + "example-cluster", + [client.lke.node_pool(node_type, 3), client.lke.node_pool(node_type_2, 3)], + kube_version + ) + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-create + + :param region: The Region to create this LKE Cluster in. + :type region: Region or str + :param label: The label for the new LKE Cluster. + :type label: str + :param node_pools: The Node Pools to create. + :type node_pools: one or a list of dicts containing keys "type" and "count". See + :any:`node_pool` for a convenient way to create correctly- + formatted dicts. + :param kube_version: The version of Kubernetes to use + :type kube_version: KubeVersion or str + :param kwargs: Any other arguments to pass along to the API. See the API + docs for possible values. + + :returns: The new LKE Cluster + :rtype: LKECluster + """ + pools = [] + if not isinstance(node_pools, list): + node_pools = [node_pools] + + for c in node_pools: + if isinstance(c, dict): + new_pool = { + "type": c["type"].id + if "type" in c and issubclass(type(c["type"]), Base) + else c.get("type"), + "count": c.get("count"), + } + + pools += [new_pool] + + params = { + "label": label, + "region": region.id if issubclass(type(region), Base) else region, + "node_pools": pools, + "k8s_version": kube_version.id + if issubclass(type(kube_version), Base) + else kube_version, + } + params.update(kwargs) + + result = self.client.post("/lke/clusters", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating LKE cluster!", json=result + ) + + return LKECluster(self.client, result["id"], result) + + def node_pool(self, node_type, node_count): + """ + Returns a dict that is suitable for passing into the `node_pools` array + of :any:`cluster_create`. This is a convenience method, and need not be + used to create Node Pools. For proper usage, see the docs for :any:`cluster_create`. + + :param node_type: The type of node to create in this node pool. + :type node_type: Type or str + :param node_count: The number of nodes to create in this node pool. + :type node_count: int + + :returns: A dict describing the desired node pool. + :rtype: dict + """ + return { + "type": node_type, + "count": node_count, + } diff --git a/linode_api4/groups/longview.py b/linode_api4/groups/longview.py new file mode 100644 index 000000000..2bee86450 --- /dev/null +++ b/linode_api4/groups/longview.py @@ -0,0 +1,62 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import LongviewClient, LongviewSubscription + + +class LongviewGroup(Group): + """ + Collections related to Linode Longview. + """ + + def clients(self, *filters): + """ + Requests and returns a paginated list of LongviewClients on your + account. + + API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Longview Clients matching the given filters. + :rtype: PaginatedList of LongviewClient + """ + return self.client._get_and_filter(LongviewClient, *filters) + + def client_create(self, label=None): + """ + Creates a new LongviewClient, optionally with a given label. + + API Documentation: https://www.linode.com/docs/api/longview/#longview-client-create + + :param label: The label for the new client. If None, a default label based + on the new client's ID will be used. + + :returns: A new LongviewClient + + :raises ApiError: If a non-200 status code is returned + :raises UnexpectedResponseError: If the returned data from the api does + not look as expected. + """ + result = self.client.post("/longview/clients", data={"label": label}) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Longview Client!", + json=result, + ) + + c = LongviewClient(self.client, result["id"], result) + return c + + def subscriptions(self, *filters): + """ + Requests and returns a paginated list of LongviewSubscriptions available + + API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Longview Subscriptions matching the given filters. + :rtype: PaginatedList of LongviewSubscription + """ + return self.client._get_and_filter(LongviewSubscription, *filters) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py new file mode 100644 index 000000000..87c84d922 --- /dev/null +++ b/linode_api4/groups/networking.py @@ -0,0 +1,330 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + VLAN, + Base, + Firewall, + Instance, + IPAddress, + IPv6Pool, + IPv6Range, + Region, +) + + +class NetworkingGroup(Group): + """ + Collections related to Linode Networking. + """ + + def firewalls(self, *filters): + """ + Retrieves the Firewalls your user has access to. + + API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Firewalls the acting user can access. + :rtype: PaginatedList of Firewall + """ + return self.client._get_and_filter(Firewall, *filters) + + def firewall_create(self, label, rules, **kwargs): + """ + Creates a new Firewall, either in the given Region or + attached to the given Instance. + + API Documentation: https://www.linode.com/docs/api/networking/#firewall-create + + :param label: The label for the new Firewall. + :type label: str + :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. + :type rules: dict + + :returns: The new Firewall. + :rtype: Firewall + + Example usage:: + + rules = { + 'outbound': [ + { + 'action': 'ACCEPT', + 'addresses': { + 'ipv4': [ + '0.0.0.0/0' + ], + 'ipv6': [ + "ff00::/8" + ] + }, + 'description': 'Allow HTTP out.', + 'label': 'allow-http-out', + 'ports': '80', + 'protocol': 'TCP' + } + ], + 'outbound_policy': 'DROP', + 'inbound': [], + 'inbound_policy': 'DROP' + } + + firewall = client.networking.firewall_create('my-firewall', rules) + + .. _Firewalls Documentation: https://www.linode.com/docs/api/networking/#firewall-create__request-body-schema + """ + + params = { + "label": label, + "rules": rules, + } + params.update(kwargs) + + result = self.client.post("/networking/firewalls", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Firewall!", json=result + ) + + f = Firewall(self.client, result["id"], result) + return f + + def ips(self, *filters): + """ + Returns a list of IP addresses on this account, excluding private addresses. + + API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of IP addresses on this account. + :rtype: PaginatedList of IPAddress + """ + return self.client._get_and_filter(IPAddress, *filters) + + def ipv6_ranges(self, *filters): + """ + Returns a list of IPv6 ranges on this account. + + API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of IPv6 ranges on this account. + :rtype: PaginatedList of IPv6Range + """ + return self.client._get_and_filter(IPv6Range, *filters) + + def ipv6_pools(self, *filters): + """ + Returns a list of IPv6 pools on this account. + + API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of IPv6 pools on this account. + :rtype: PaginatedList of IPv6Pool + """ + + return self.client._get_and_filter(IPv6Pool, *filters) + + def vlans(self, *filters): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + Returns a list of VLANs on your account. + + API Documentation: https://www.linode.com/docs/api/networking/#vlans-list + + :param filters: Any number of filters to apply to this query. + + :returns: A List of VLANs on your account. + :rtype: PaginatedList of VLAN + """ + return self.client._get_and_filter(VLAN, *filters) + + def ips_assign(self, region, *assignments): + """ + Redistributes :any:`IP Addressees` within a single region. + This function takes a :any:`Region` and a list of assignments to make, + then requests that the assignments take place. If any :any:`Instance` + ends up without a public IP, or with more than one private IP, all of + the assignments will fail. + + .. note:: + This function *does not* update the local Linode Instance objects + when called. In order to see the new addresses on the local + instance objects, be sure to invalidate them with ``invalidate()`` + after this completes. + + Example usage:: + + linode1 = Instance(client, 123) + linode2 = Instance(client, 456) + + # swap IPs between linodes 1 and 2 + client.networking.assign_ips(linode1.region, + linode1.ips.ipv4.public[0].to(linode2), + linode2.ips.ipv4.public[0].to(linode1)) + + # make sure linode1 and linode2 have updated ipv4 and ips values + linode1.invalidate() + linode2.invalidate() + + API Documentation: https://www.linode.com/docs/api/networking/#linodes-assign-ipv4s + + :param region: The Region in which the assignments should take place. + All Instances and IPAddresses involved in the assignment + must be within this region. + :type region: str or Region + :param assignments: Any number of assignments to make. See + :any:`IPAddress.to` for details on how to construct + assignments. + :type assignments: dct + + DEPRECATED: Use ip_addresses_assign() instead + """ + for a in assignments: + if not "address" in a or not "linode_id" in a: + raise ValueError("Invalid assignment: {}".format(a)) + if isinstance(region, Region): + region = region.id + + self.client.post( + "/networking/ipv4/assign", + data={ + "region": region, + "assignments": assignments, + }, + ) + + def ip_allocate(self, linode, public=True): + """ + Allocates an IP to a Instance you own. Additional IPs must be requested + by opening a support ticket first. + + API Documentation: https://www.linode.com/docs/api/networking/#ip-address-allocate + + :param linode: The Instance to allocate the new IP for. + :type linode: Instance or int + :param public: If True, allocate a public IP address. Defaults to True. + :type public: bool + + :returns: The new IPAddress. + :rtype: IPAddress + """ + result = self.client.post( + "/networking/ips/", + data={ + "linode_id": linode.id if isinstance(linode, Base) else linode, + "type": "ipv4", + "public": public, + }, + ) + + if not "address" in result: + raise UnexpectedResponseError( + "Unexpected response when adding IPv4 address!", json=result + ) + + ip = IPAddress(self.client, result["address"], result) + return ip + + def ips_share(self, linode, *ips): + """ + Shares the given list of :any:`IPAddresses` with the provided + :any:`Instance`. This will enable the provided Instance to bring up the + shared IP Addresses even though it does not own them. + + API Documentation: https://www.linode.com/docs/api/networking/#ipv4-sharing-configure + + :param linode: The Instance to share the IPAddresses with. This Instance + will be able to bring up the given addresses. + :type: linode: int or Instance + :param ips: Any number of IPAddresses to share to the Instance. + :type ips: str or IPAddress + + DEPRECATED: Use ip_addresses_share() instead + """ + if not isinstance(linode, Instance): + # make this an object + linode = Instance(self.client, linode) + + params = [] + for ip in ips: + if isinstance(ip, str): + params.append(ip) + elif isinstance(ip, IPAddress): + params.append(ip.address) + else: + params.append(str(ip)) # and hope that works + + params = {"ips": params} + + self.client.post( + "{}/networking/ipv4/share".format(Instance.api_endpoint), + model=linode, + data=params, + ) + + linode.invalidate() # clear the Instance's shared IPs + + def ip_addresses_share(self, ips, linode): + """ + Configure shared IPs. P sharing allows IP address reassignment + (also referred to as IP failover) from one Linode to another if the + primary Linode becomes unresponsive. This means that requests to the primary Linode’s + IP address can be automatically rerouted to secondary Linodes at the configured shared IP addresses. + + :param linode: The id of the Instance or the Instance to share the IPAddresses with. + This Instance will be able to bring up the given addresses. + :type: linode: int or Instance + :param ips: Any number of IPAddresses to share to the Instance. + :type ips: str or IPAddress + """ + + params = { + "ips": ips + if not isinstance(ips[0], IPAddress) + else [ip.address for ip in ips], + "linode_id": linode + if not isinstance(linode, Instance) + else linode.id, + } + + self.client.post("/networking/ips/share", model=self, data=params) + + def ip_addresses_assign(self, assignments, region): + """ + Assign multiple IPv4 addresses and/or IPv6 ranges to multiple Linodes in one Region. + This allows swapping, shuffling, or otherwise reorganizing IPs to your Linodes. + + The following restrictions apply: + - All Linodes involved must have at least one public IPv4 address after assignment. + - Linodes may have no more than one assigned private IPv4 address. + - Linodes may have no more than one assigned IPv6 range. + + + :param region: The Region in which the assignments should take place. + All Instances and IPAddresses involved in the assignment + must be within this region. + :type region: str or Region + :param assignments: Any number of assignments to make. See + :any:`IPAddress.to` for details on how to construct + assignments. + :type assignments: dct + """ + + for a in assignments["assignments"]: + if not "address" in a or not "linode_id" in a: + raise ValueError("Invalid assignment: {}".format(a)) + + if isinstance(region, Region): + region = region.id + + params = {"assignments": assignments, "region": region} + + self.client.post("/networking/ips/assign", model=self, data=params) diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py new file mode 100644 index 000000000..72bd12ea1 --- /dev/null +++ b/linode_api4/groups/nodebalancer.py @@ -0,0 +1,50 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Base, NodeBalancer + + +class NodeBalancerGroup(Group): + def __call__(self, *filters): + """ + Retrieves all of the NodeBalancers the acting user has access to. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + nodebalancers = client.nodebalancers() + + API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of NodeBalancers the acting user can access. + :rtype: PaginatedList of NodeBalancers + """ + return self.client._get_and_filter(NodeBalancer, *filters) + + def create(self, region, **kwargs): + """ + Creates a new NodeBalancer in the given Region. + + API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-create + + :param region: The Region in which to create the NodeBalancer. + :type region: Region or str + + :returns: The new NodeBalancer + :rtype: NodeBalancer + """ + params = { + "region": region.id if isinstance(region, Base) else region, + } + params.update(kwargs) + + result = self.client.post("/nodebalancers", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Nodebalaner!", json=result + ) + + n = NodeBalancer(self.client, result["id"], result) + return n diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py new file mode 100644 index 000000000..78245d246 --- /dev/null +++ b/linode_api4/groups/obj.py @@ -0,0 +1,156 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Base, ObjectStorageCluster, ObjectStorageKeys + + +class ObjectStorageGroup(Group): + """ + This group encapsulates all endpoints under /object-storage, including viewing + available clusters and managing keys. + """ + + def clusters(self, *filters): + """ + Returns a list of available Object Storage Clusters. You may filter + this query to return only Clusters that are available in a specific region:: + + us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") + + API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Object Storage Clusters that matched the query. + :rtype: PaginatedList of ObjectStorageCluster + """ + return self.client._get_and_filter(ObjectStorageCluster, *filters) + + def keys(self, *filters): + """ + Returns a list of Object Storage Keys active on this account. These keys + allow third-party applications to interact directly with Linode Object Storage. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Object Storage Keys that matched the query. + :rtype: PaginatedList of ObjectStorageKeys + """ + return self.client._get_and_filter(ObjectStorageKeys, *filters) + + def keys_create(self, label, bucket_access=None): + """ + Creates a new Object Storage keypair that may be used to interact directly + with Linode Object Storage in third-party applications. This response is + the only time that "secret_key" will be populated - be sure to capture its + value or it will be lost forever. + + If given, `bucket_access` will cause the new keys to be restricted to only + the specified level of access for the specified buckets. For example, to + create a keypair that can only access the "example" bucket in all clusters + (and assuming you own that bucket in every cluster), you might do this:: + + client = LinodeClient(TOKEN) + + # look up clusters + all_clusters = client.object_storage.clusters() + + new_keys = client.object_storage.keys_create( + "restricted-keys", + bucket_access=[ + client.object_storage.bucket_access(cluster, "example", "read_write") + for cluster in all_clusters + ], + ) + + To create a keypair that can only read from the bucket "example2" in the + "us-east-1" cluster (an assuming you own that bucket in that cluster), + you might do this:: + + client = LinodeClient(TOKEN) + new_keys_2 = client.object_storage.keys_create( + "restricted-keys-2", + bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), + ) + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create + + :param label: The label for this keypair, for identification only. + :type label: str + :param bucket_access: One or a list of dicts with keys "cluster," + "permissions", and "bucket_name". If given, the + resulting Object Storage keys will only have the + requested level of access to the requested buckets, + if they exist and are owned by you. See the provided + :any:`bucket_access` function for a convenient way + to create these dicts. + :type bucket_access: dict or list of dict + + :returns: The new keypair, with the secret key populated. + :rtype: ObjectStorageKeys + """ + params = {"label": label} + + if bucket_access is not None: + if not isinstance(bucket_access, list): + bucket_access = [bucket_access] + + ba = [ + { + "permissions": c.get("permissions"), + "bucket_name": c.get("bucket_name"), + "cluster": c.id + if "cluster" in c and issubclass(type(c["cluster"]), Base) + else c.get("cluster"), + } + for c in bucket_access + ] + + params["bucket_access"] = ba + + result = self.client.post("/object-storage/keys", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Object Storage Keys!", + json=result, + ) + + ret = ObjectStorageKeys(self.client, result["id"], result) + return ret + + def bucket_access(self, cluster, bucket_name, permissions): + """ + Returns a dict formatted to be included in the `bucket_access` argument + of :any:`keys_create`. See the docs for that method for an example of + usage. + + :param cluster: The Object Storage cluster to grant access in. + :type cluster: :any:`ObjectStorageCluster` or str + :param bucket_name: The name of the bucket to grant access to. + :type bucket_name: str + :param permissions: The permissions to grant. Should be one of "read_only" + or "read_write". + :type permissions: str + + :returns: A dict formatted correctly for specifying bucket access for + new keys. + :rtype: dict + """ + return { + "cluster": cluster, + "bucket_name": bucket_name, + "permissions": permissions, + } + + def cancel(self): + """ + Cancels Object Storage service. This may be a destructive operation. Once + cancelled, you will no longer receive the transfer for or be billed for + Object Storage, and all keys will be invalidated. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel + """ + self.client.post("/object-storage/cancel", data={}) + return True diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py new file mode 100644 index 000000000..8748ec8c7 --- /dev/null +++ b/linode_api4/groups/profile.py @@ -0,0 +1,159 @@ +import os +from datetime import datetime + +from linode_api4 import UnexpectedResponseError +from linode_api4.common import SSH_KEY_TYPES +from linode_api4.groups import Group +from linode_api4.objects import ( + AuthorizedApp, + PersonalAccessToken, + Profile, + SSHKey, +) + + +class ProfileGroup(Group): + """ + Collections related to your user. + """ + + def __call__(self): + """ + Retrieve the acting user's Profile, containing information about the + current user such as their email address, username, and uid. This is + intended to be called off of a :any:`LinodeClient` object, like this:: + + profile = client.profile() + + API Documentation: https://www.linode.com/docs/api/profile/#profile-view + + :returns: The acting user's profile. + :rtype: Profile + """ + result = self.client.get("/profile") + + if not "username" in result: + raise UnexpectedResponseError( + "Unexpected response when getting profile!", json=result + ) + + p = Profile(self.client, result["username"], result) + return p + + def tokens(self, *filters): + """ + Returns the Person Access Tokens active for this user. + + API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of tokens that matches the query. + :rtype: PaginatedList of PersonalAccessToken + """ + return self.client._get_and_filter(PersonalAccessToken, *filters) + + def token_create(self, label=None, expiry=None, scopes=None, **kwargs): + """ + Creates and returns a new Personal Access Token. + + API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-create + + :param label: The label of the new Personal Access Token. + :type label: str + :param expiry: When the new Personal Accses Token will expire. + :type expiry: datetime or str + :param scopes: A space-separated list of OAuth scopes for this token. + :type scopes: str + + :returns: The new Personal Access Token. + :rtype: PersonalAccessToken + """ + if label: + kwargs["label"] = label + if expiry: + if isinstance(expiry, datetime): + expiry = datetime.strftime(expiry, "%Y-%m-%dT%H:%M:%S") + kwargs["expiry"] = expiry + if scopes: + kwargs["scopes"] = scopes + + result = self.client.post("/profile/tokens", data=kwargs) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Personal Access Token!", + json=result, + ) + + token = PersonalAccessToken(self.client, result["id"], result) + return token + + def apps(self, *filters): + """ + Returns the Authorized Applications for this user + + API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Authorized Applications for this user + :rtype: PaginatedList of AuthorizedApp + """ + return self.client._get_and_filter(AuthorizedApp, *filters) + + def ssh_keys(self, *filters): + """ + Returns the SSH Public Keys uploaded to your profile. + + API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of SSH Keys for this profile. + :rtype: PaginatedList of SSHKey + """ + return self.client._get_and_filter(SSHKey, *filters) + + def ssh_key_upload(self, key, label): + """ + Uploads a new SSH Public Key to your profile This key can be used in + later Linode deployments. + + API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-add + + :param key: The ssh key, or a path to the ssh key. If a path is provided, + the file at the path must exist and be readable or an exception + will be thrown. + :type key: str + :param label: The name to give this key. This is purely aesthetic. + :type label: str + + :returns: The newly uploaded SSH Key + :rtype: SSHKey + :raises ValueError: If the key provided does not appear to be valid, and + does not appear to be a path to a valid key. + """ + if not key.startswith(SSH_KEY_TYPES): + # this might be a file path - look for it + path = os.path.expanduser(key) + if os.path.isfile(path): + with open(path) as f: + key = f.read().strip() + if not key.startswith(SSH_KEY_TYPES): + raise ValueError("Invalid SSH Public Key") + + params = { + "ssh_key": key, + "label": label, + } + + result = self.client.post("/profile/sshkeys", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when uploading SSH Key!", json=result + ) + + ssh_key = SSHKey(self.client, result["id"], result) + return ssh_key diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py new file mode 100644 index 000000000..be6440f73 --- /dev/null +++ b/linode_api4/groups/region.py @@ -0,0 +1,23 @@ +from linode_api4.groups import Group +from linode_api4.objects import Region + + +class RegionGroup(Group): + def __call__(self, *filters): + """ + Returns the available Regions for Linode products. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + region = client.regions() + + API Documentation: https://www.linode.com/docs/api/regions/#regions-list + + :param filters: Any number of filters to apply to the query. + + :returns: A list of available Regions. + :rtype: PaginatedList of Region + """ + + return self.client._get_and_filter(Region, *filters) diff --git a/linode_api4/groups/support.py b/linode_api4/groups/support.py new file mode 100644 index 000000000..faa9547cb --- /dev/null +++ b/linode_api4/groups/support.py @@ -0,0 +1,103 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + VLAN, + Database, + Domain, + Firewall, + Instance, + LKECluster, + LongviewClient, + NodeBalancer, + SupportTicket, + Volume, +) + + +class SupportGroup(Group): + """ + Collections related to support tickets. + """ + + def tickets(self, *filters): + """ + Returns a list of support tickets on this account. + + API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of support tickets on this account. + :rtype: PaginatedList of SupportTicket + """ + + return self.client._get_and_filter(SupportTicket, *filters) + + def ticket_open( + self, + summary, + description, + managed_issue=False, + regarding=None, + **kwargs, + ): + """ + Opens a support ticket on this account. + + API Documentation: https://www.linode.com/docs/api/support/#support-ticket-open + + :param summary: The summary or title for this support ticket. + :type summary: str + :param description: The full details of the issue or question. + :type description: str + :param regarding: The resource being referred to in this ticket. + :type regarding: + :param managed_issue: Designates if this ticket relates to a managed service. + :type managed_issue: bool + + :returns: The new support ticket. + :rtype: SupportTicket + """ + params = { + "summary": summary, + "description": description, + "managed_issue": managed_issue, + } + + type_to_id = { + Instance: "linode_id", + Domain: "domain_id", + NodeBalancer: "nodebalancer_id", + Volume: "volume_id", + Firewall: "firewall_id", + LKECluster: "lkecluster_id", + Database: "database_id", + LongviewClient: "longviewclient_id", + } + + params.update(kwargs) + + if regarding: + id_attr = type_to_id.get(type(regarding)) + + if id_attr is not None: + params[id_attr] = regarding.id + elif isinstance(regarding, VLAN): + params["vlan"] = regarding.label + params["region"] = regarding.region + else: + raise ValueError( + "Cannot open ticket regarding type {}!".format( + type(regarding) + ) + ) + + result = self.client.post("/support/tickets", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating ticket!", json=result + ) + + t = SupportTicket(self.client, result["id"], result) + return t diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py new file mode 100644 index 000000000..6276897fa --- /dev/null +++ b/linode_api4/groups/tag.py @@ -0,0 +1,114 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Domain, Instance, NodeBalancer, Tag, Volume + + +class TagGroup(Group): + def __call__(self, *filters): + """ + Retrieves the Tags on your account. This may only be attempted by + unrestricted users. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + tags = client.tags() + + API Documentation: https://www.linode.com/docs/api/domains/#domain-create + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Tags on the account. + :rtype: PaginatedList of Tag + """ + return self.client._get_and_filter(Tag, *filters) + + def create( + self, + label, + instances=None, + domains=None, + nodebalancers=None, + volumes=None, + entities=[], + ): + """ + Creates a new Tag and optionally applies it to the given entities. + + API Documentation: https://www.linode.com/docs/api/tags/#tags-list + + :param label: The label for the new Tag + :type label: str + :param entities: A list of objects to apply this Tag to upon creation. + May only be taggable types (Linode Instances, Domains, + NodeBalancers, or Volumes). These are applied *in addition + to* any IDs specified with ``instances``, ``domains``, + ``nodebalancers``, or ``volumes``, and is a convenience + for sending multiple entity types without sorting them + yourself. + :type entities: list of Instance, Domain, NodeBalancer, and/or Volume + :param instances: A list of Linode Instances to apply this Tag to upon + creation + :type instances: list of Instance or list of int + :param domains: A list of Domains to apply this Tag to upon + creation + :type domains: list of Domain or list of int + :param nodebalancers: A list of NodeBalancers to apply this Tag to upon + creation + :type nodebalancers: list of NodeBalancer or list of int + :param volumes: A list of Volumes to apply this Tag to upon + creation + :type volumes: list of Volumes or list of int + + :returns: The new Tag + :rtype: Tag + """ + linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], [] + + # filter input into lists of ids + sorter = zip( + (linode_ids, nodebalancer_ids, domain_ids, volume_ids), + (instances, nodebalancers, domains, volumes), + ) + + for id_list, input_list in sorter: + # if we got something, we need to find its ID + if input_list is not None: + for cur in input_list: + if isinstance(cur, int): + id_list.append(cur) + else: + id_list.append(cur.id) + + # filter entities into id lists too + type_map = { + Instance: linode_ids, + NodeBalancer: nodebalancer_ids, + Domain: domain_ids, + Volume: volume_ids, + } + + for e in entities: + if type(e) in type_map: + type_map[type(e)].append(e.id) + else: + raise ValueError("Unsupported entity type {}".format(type(e))) + + # finally, omit all id lists that are empty + params = { + "label": label, + "linodes": linode_ids or None, + "nodebalancers": nodebalancer_ids or None, + "domains": domain_ids or None, + "volumes": volume_ids or None, + } + + result = self.client.post("/tags", data=params) + + if not "label" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Tag!", json=result + ) + + t = Tag(self.client, result["label"], result) + return t diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py new file mode 100644 index 000000000..54a9829a5 --- /dev/null +++ b/linode_api4/groups/volume.py @@ -0,0 +1,71 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Base, Volume + + +class VolumeGroup(Group): + def __call__(self, *filters): + """ + Retrieves the Block Storage Volumes your user has access to. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + volumes = client.volumes() + + API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Volumes the acting user can access. + :rtype: PaginatedList of Volume + """ + return self.client._get_and_filter(Volume, *filters) + + def create(self, label, region=None, linode=None, size=20, **kwargs): + """ + Creates a new Block Storage Volume, either in the given Region or + attached to the given Instance. + + API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list + + :param label: The label for the new Volume. + :type label: str + :param region: The Region to create this Volume in. Not required if + `linode` is provided. + :type region: Region or str + :param linode: The Instance to attach this Volume to. If not given, the + new Volume will not be attached to anything. + :type linode: Instance or int + :param size: The size, in GB, of the new Volume. Defaults to 20. + :type size: int + :param tags: A list of tags to apply to the new volume. If any of the + tags included do not exist, they will be created as part of + this operation. + :type tags: list[str] + + :returns: The new Volume. + :rtype: Volume + """ + if not (region or linode): + raise ValueError("region or linode required!") + + params = { + "label": label, + "size": size, + "region": region.id if issubclass(type(region), Base) else region, + "linode_id": linode.id + if issubclass(type(linode), Base) + else linode, + } + params.update(kwargs) + + result = self.client.post("/volumes", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating volume!", json=result + ) + + v = Volume(self.client, result["id"], result) + return v diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index eac7e5a5d..8d85515be 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -2,15 +2,14 @@ import json import logging -import os import time -from datetime import datetime from typing import BinaryIO, Tuple import pkg_resources import requests from linode_api4.errors import ApiError, UnexpectedResponseError +from linode_api4.groups import * from linode_api4.objects import * from linode_api4.objects.filtering import Filter @@ -23,1940 +22,6 @@ logger = logging.getLogger(__name__) -class Group: - def __init__(self, client: LinodeClient): - self.client = client - - -class LinodeGroup(Group): - """ - Encapsulates Linode-related methods of the :any:`LinodeClient`. This - should not be instantiated on its own, but should instead be used through - an instance of :any:`LinodeClient`:: - - client = LinodeClient(token) - instances = client.linode.instances() # use the LinodeGroup - - This group contains all features beneath the `/linode` group in the API v4. - """ - - def types(self, *filters): - """ - Returns a list of Linode Instance types. These may be used to create - or resize Linodes, or simply referenced on their own. Types can be - filtered to return specific types, for example:: - - standard_types = client.linode.types(Type.class == "standard") - - API documentation: https://www.linode.com/docs/api/linode-types/#types-list - - :param filters: Any number of filters to apply to the query. - - :returns: A list of types that match the query. - :rtype: PaginatedList of Type - """ - return self.client._get_and_filter(Type, *filters) - - def instances(self, *filters): - """ - Returns a list of Linode Instances on your account. You may filter - this query to return only Linodes that match specific criteria:: - - prod_linodes = client.linode.instances(Instance.group == "prod") - - API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Instances that matched the query. - :rtype: PaginatedList of Instance - """ - return self.client._get_and_filter(Instance, *filters) - - def stackscripts(self, *filters, **kwargs): - """ - Returns a list of :any:`StackScripts`, both public and - private. You may filter this query to return only - :any:`StackScripts` that match certain criteria. You may - also request only your own private :any:`StackScripts`:: - - my_stackscripts = client.linode.stackscripts(mine_only=True) - - API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list - - :param filters: Any number of filters to apply to this query. - :param mine_only: If True, returns only private StackScripts - :type mine_only: bool - - :returns: A list of StackScripts matching the query. - :rtype: PaginatedList of StackScript - """ - # python2 can't handle *args and a single keyword argument, so this is a workaround - if "mine_only" in kwargs: - if kwargs["mine_only"]: - new_filter = Filter({"mine": True}) - if filters: - filters = list(filters) - filters[0] = filters[0] & new_filter - else: - filters = [new_filter] - - del kwargs["mine_only"] - - if kwargs: - raise TypeError( - "stackscripts() got unexpected keyword argument '{}'".format( - kwargs.popitem()[0] - ) - ) - - return self.client._get_and_filter(StackScript, *filters) - - def kernels(self, *filters): - """ - Returns a list of available :any:`Kernels`. Kernels are used - when creating or updating :any:`LinodeConfigs,LinodeConfig>`. - - API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of available kernels that match the query. - :rtype: PaginatedList of Kernel - """ - return self.client._get_and_filter(Kernel, *filters) - - # create things - def instance_create( - self, ltype, region, image=None, authorized_keys=None, **kwargs - ): - """ - Creates a new Linode Instance. This function has several modes of operation: - - **Create an Instance from an Image** - - To create an Instance from an :any:`Image`, call `instance_create` with - a :any:`Type`, a :any:`Region`, and an :any:`Image`. All three of - these fields may be provided as either the ID or the appropriate object. - In this mode, a root password will be generated and returned with the - new Instance object. For example:: - - new_linode, password = client.linode.instance_create( - "g6-standard-2", - "us-east", - image="linode/debian9") - - ltype = client.linode.types().first() - region = client.regions().first() - image = client.images().first() - - another_linode, password = client.linode.instance_create( - ltype, - region, - image=image) - - **Create an Instance from StackScript** - - When creating an Instance from a :any:`StackScript`, an :any:`Image` that - the StackScript support must be provided.. You must also provide any - required StackScript data for the script's User Defined Fields.. For - example, if deploying `StackScript 10079`_ (which deploys a new Instance - with a user created from keys on `github`_:: - - stackscript = StackScript(client, 10079) - - new_linode, password = client.linode.instance_create( - "g6-standard-2", - "us-east", - image="linode/debian9", - stackscript=stackscript, - stackscript_data={"gh_username": "example"}) - - In the above example, "gh_username" is the name of a User Defined Field - in the chosen StackScript. For more information on StackScripts, see - the `StackScript guide`_. - - .. _`StackScript 10079`: https://www.linode.com/stackscripts/view/10079 - .. _`github`: https://github.com - .. _`StackScript guide`: https://www.linode.com/docs/platform/stackscripts/ - - **Create an Instance from a Backup** - - To create a new Instance by restoring a :any:`Backup` to it, provide a - :any:`Type`, a :any:`Region`, and the :any:`Backup` to restore. You - may provide either IDs or objects for all of these fields:: - - existing_linode = Instance(client, 123) - snapshot = existing_linode.available_backups.snapshot.current - - new_linode = client.linode.instance_create( - "g6-standard-2", - "us-east", - backup=snapshot) - - **Create an empty Instance** - - If you want to create an empty Instance that you will configure manually, - simply call `instance_create` with a :any:`Type` and a :any:`Region`:: - - empty_linode = client.linode.instance_create("g6-standard-2", "us-east") - - When created this way, the Instance will not be booted and cannot boot - successfully until disks and configs are created, or it is otherwise - configured. - - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-create - - :param ltype: The Instance Type we are creating - :type ltype: str or Type - :param region: The Region in which we are creating the Instance - :type region: str or Region - :param image: The Image to deploy to this Instance. If this is provided - and no root_pass is given, a password will be generated - and returned along with the new Instance. - :type image: str or Image - :param stackscript: The StackScript to deploy to the new Instance. If - provided, "image" is required and must be compatible - with the chosen StackScript. - :type stackscript: int or StackScript - :param stackscript_data: Values for the User Defined Fields defined in - the chosen StackScript. Does nothing if - StackScript is not provided. - :type stackscript_data: dict - :param backup: The Backup to restore to the new Instance. May not be - provided if "image" is given. - :type backup: int of Backup - :param authorized_keys: The ssh public keys to install in the linode's - /root/.ssh/authorized_keys file. Each entry may - be a single key, or a path to a file containing - the key. - :type authorized_keys: list or str - :param label: The display label for the new Instance - :type label: str - :param group: The display group for the new Instance - :type group: str - :param booted: Whether the new Instance should be booted. This will - default to True if the Instance is deployed from an Image - or Backup. - :type booted: bool - :param tags: A list of tags to apply to the new instance. If any of the - tags included do not exist, they will be created as part of - this operation. - :type tags: list[str] - :param private_ip: Whether the new Instance should have private networking - enabled and assigned a private IPv4 address. - :type private_ip: bool - - :returns: A new Instance object, or a tuple containing the new Instance and - the generated password. - :rtype: Instance or tuple(Instance, str) - :raises ApiError: If contacting the API fails - :raises UnexpectedResponseError: If the API response is somehow malformed. - This usually indicates that you are using - an outdated library. - """ - ret_pass = None - if image and not "root_pass" in kwargs: - ret_pass = Instance.generate_root_password() - kwargs["root_pass"] = ret_pass - - authorized_keys = load_and_validate_keys(authorized_keys) - - if "stackscript" in kwargs: - # translate stackscripts - kwargs["stackscript_id"] = ( - kwargs["stackscript"].id - if issubclass(type(kwargs["stackscript"]), Base) - else kwargs["stackscript"] - ) - del kwargs["stackscript"] - - if "backup" in kwargs: - # translate backups - kwargs["backup_id"] = ( - kwargs["backup"].id - if issubclass(type(kwargs["backup"]), Base) - else kwargs["backup"] - ) - del kwargs["backup"] - - params = { - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - "region": region.id if issubclass(type(region), Base) else region, - "image": (image.id if issubclass(type(image), Base) else image) - if image - else None, - "authorized_keys": authorized_keys, - } - params.update(kwargs) - - result = self.client.post("/linode/instances", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating linode!", json=result - ) - - l = Instance(self.client, result["id"], result) - if not ret_pass: - return l - return l, ret_pass - - def stackscript_create( - self, label, script, images, desc=None, public=False, **kwargs - ): - """ - Creates a new :any:`StackScript` on your account. - - API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-create - - :param label: The label for this StackScript. - :type label: str - :param script: The script to run when an :any:`Instance` is deployed with - this StackScript. Must begin with a shebang (#!). - :type script: str - :param images: A list of :any:`Images` that this StackScript - supports. Instances will not be deployed from this - StackScript unless deployed from one of these Images. - :type images: list of Image - :param desc: A description for this StackScript. - :type desc: str - :param public: Whether this StackScript is public. Defaults to False. - Once a StackScript is made public, it may not be set - back to private. - :type public: bool - - :returns: The new StackScript - :rtype: StackScript - """ - image_list = None - if type(images) is list or type(images) is PaginatedList: - image_list = [ - d.id if issubclass(type(d), Base) else d for d in images - ] - elif type(images) is Image: - image_list = [images.id] - elif type(images) is str: - image_list = [images] - else: - raise ValueError( - "images must be a list of Images or a single Image" - ) - - script_body = script - if not script.startswith("#!"): - # it doesn't look like a stackscript body, let's see if it's a file - if os.path.isfile(script): - with open(script) as f: - script_body = f.read() - else: - raise ValueError( - "script must be the script text or a path to a file" - ) - - params = { - "label": label, - "images": image_list, - "is_public": public, - "script": script_body, - "description": desc if desc else "", - } - params.update(kwargs) - - result = self.client.post("/linode/stackscripts", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating StackScript!", json=result - ) - - s = StackScript(self.client, result["id"], result) - return s - - -class ProfileGroup(Group): - """ - Collections related to your user. - """ - - def __call__(self): - """ - Retrieve the acting user's Profile, containing information about the - current user such as their email address, username, and uid. This is - intended to be called off of a :any:`LinodeClient` object, like this:: - - profile = client.profile() - - API Documentation: https://www.linode.com/docs/api/profile/#profile-view - - :returns: The acting user's profile. - :rtype: Profile - """ - result = self.client.get("/profile") - - if not "username" in result: - raise UnexpectedResponseError( - "Unexpected response when getting profile!", json=result - ) - - p = Profile(self.client, result["username"], result) - return p - - def tokens(self, *filters): - """ - Returns the Person Access Tokens active for this user. - - API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of tokens that matches the query. - :rtype: PaginatedList of PersonalAccessToken - """ - return self.client._get_and_filter(PersonalAccessToken, *filters) - - def token_create(self, label=None, expiry=None, scopes=None, **kwargs): - """ - Creates and returns a new Personal Access Token. - - API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-create - - :param label: The label of the new Personal Access Token. - :type label: str - :param expiry: When the new Personal Accses Token will expire. - :type expiry: datetime or str - :param scopes: A space-separated list of OAuth scopes for this token. - :type scopes: str - - :returns: The new Personal Access Token. - :rtype: PersonalAccessToken - """ - if label: - kwargs["label"] = label - if expiry: - if isinstance(expiry, datetime): - expiry = datetime.strftime(expiry, "%Y-%m-%dT%H:%M:%S") - kwargs["expiry"] = expiry - if scopes: - kwargs["scopes"] = scopes - - result = self.client.post("/profile/tokens", data=kwargs) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Personal Access Token!", - json=result, - ) - - token = PersonalAccessToken(self.client, result["id"], result) - return token - - def apps(self, *filters): - """ - Returns the Authorized Applications for this user - - API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Authorized Applications for this user - :rtype: PaginatedList of AuthorizedApp - """ - return self.client._get_and_filter(AuthorizedApp, *filters) - - def ssh_keys(self, *filters): - """ - Returns the SSH Public Keys uploaded to your profile. - - API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of SSH Keys for this profile. - :rtype: PaginatedList of SSHKey - """ - return self.client._get_and_filter(SSHKey, *filters) - - def ssh_key_upload(self, key, label): - """ - Uploads a new SSH Public Key to your profile This key can be used in - later Linode deployments. - - API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-add - - :param key: The ssh key, or a path to the ssh key. If a path is provided, - the file at the path must exist and be readable or an exception - will be thrown. - :type key: str - :param label: The name to give this key. This is purely aesthetic. - :type label: str - - :returns: The newly uploaded SSH Key - :rtype: SSHKey - :raises ValueError: If the key provided does not appear to be valid, and - does not appear to be a path to a valid key. - """ - if not key.startswith(SSH_KEY_TYPES): - # this might be a file path - look for it - path = os.path.expanduser(key) - if os.path.isfile(path): - with open(path) as f: - key = f.read().strip() - if not key.startswith(SSH_KEY_TYPES): - raise ValueError("Invalid SSH Public Key") - - params = { - "ssh_key": key, - "label": label, - } - - result = self.client.post("/profile/sshkeys", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when uploading SSH Key!", json=result - ) - - ssh_key = SSHKey(self.client, result["id"], result) - return ssh_key - - -class LKEGroup(Group): - """ - Encapsulates LKE-related methods of the :any:`LinodeClient`. This - should not be instantiated on its own, but should instead be used through - an instance of :any:`LinodeClient`:: - - client = LinodeClient(token) - instances = client.lke.clusters() # use the LKEGroup - - This group contains all features beneath the `/lke` group in the API v4. - """ - - def versions(self, *filters): - """ - Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be - used when creating an LKE Cluster. - - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list - - :param filters: Any number of filters to apply to the query. - - :returns: A Paginated List of kube versions that match the query. - :rtype: PaginatedList of KubeVersion - """ - return self.client._get_and_filter(KubeVersion, *filters) - - def clusters(self, *filters): - """ - Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong - to this account. - - https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list - - :param filters: Any number of filters to apply to the query. - - :returns: A Paginated List of LKE clusters that match the query. - :rtype: PaginatedList of LKECluster - """ - return self.client._get_and_filter(LKECluster, *filters) - - def cluster_create(self, region, label, node_pools, kube_version, **kwargs): - """ - Creates an :any:`LKECluster` on this account in the given region, with - the given label, and with node pools as described. For example:: - - client = LinodeClient(TOKEN) - - # look up Region and Types to use. In this example I'm just using - # the first ones returned. - target_region = client.regions().first() - node_type = client.linode.types()[0] - node_type_2 = client.linode.types()[1] - kube_version = client.lke.versions()[0] - - new_cluster = client.lke.cluster_create( - target_region, - "example-cluster", - [client.lke.node_pool(node_type, 3), client.lke.node_pool(node_type_2, 3)], - kube_version - ) - - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-create - - :param region: The Region to create this LKE Cluster in. - :type region: Region or str - :param label: The label for the new LKE Cluster. - :type label: str - :param node_pools: The Node Pools to create. - :type node_pools: one or a list of dicts containing keys "type" and "count". See - :any:`node_pool` for a convenient way to create correctly- - formatted dicts. - :param kube_version: The version of Kubernetes to use - :type kube_version: KubeVersion or str - :param kwargs: Any other arguments to pass along to the API. See the API - docs for possible values. - - :returns: The new LKE Cluster - :rtype: LKECluster - """ - pools = [] - if not isinstance(node_pools, list): - node_pools = [node_pools] - - for c in node_pools: - if isinstance(c, dict): - new_pool = { - "type": c["type"].id - if "type" in c and issubclass(type(c["type"]), Base) - else c.get("type"), - "count": c.get("count"), - } - - pools += [new_pool] - - params = { - "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "node_pools": pools, - "k8s_version": kube_version.id - if issubclass(type(kube_version), Base) - else kube_version, - } - params.update(kwargs) - - result = self.client.post("/lke/clusters", data=params) - - if "id" not in result: - raise UnexpectedResponseError( - "Unexpected response when creating LKE cluster!", json=result - ) - - return LKECluster(self.client, result["id"], result) - - def node_pool(self, node_type, node_count): - """ - Returns a dict that is suitable for passing into the `node_pools` array - of :any:`cluster_create`. This is a convenience method, and need not be - used to create Node Pools. For proper usage, see the docs for :any:`cluster_create`. - - :param node_type: The type of node to create in this node pool. - :type node_type: Type or str - :param node_count: The number of nodes to create in this node pool. - :type node_count: int - - :returns: A dict describing the desired node pool. - :rtype: dict - """ - return { - "type": node_type, - "count": node_count, - } - - -class LongviewGroup(Group): - """ - Collections related to Linode Longview. - """ - - def clients(self, *filters): - """ - Requests and returns a paginated list of LongviewClients on your - account. - - API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Longview Clients matching the given filters. - :rtype: PaginatedList of LongviewClient - """ - return self.client._get_and_filter(LongviewClient, *filters) - - def client_create(self, label=None): - """ - Creates a new LongviewClient, optionally with a given label. - - API Documentation: https://www.linode.com/docs/api/longview/#longview-client-create - - :param label: The label for the new client. If None, a default label based - on the new client's ID will be used. - - :returns: A new LongviewClient - - :raises ApiError: If a non-200 status code is returned - :raises UnexpectedResponseError: If the returned data from the api does - not look as expected. - """ - result = self.client.post("/longview/clients", data={"label": label}) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Longview Client!", - json=result, - ) - - c = LongviewClient(self.client, result["id"], result) - return c - - def subscriptions(self, *filters): - """ - Requests and returns a paginated list of LongviewSubscriptions available - - API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Longview Subscriptions matching the given filters. - :rtype: PaginatedList of LongviewSubscription - """ - return self.client._get_and_filter(LongviewSubscription, *filters) - - -class AccountGroup(Group): - """ - Collections related to your account. - """ - - def __call__(self): - """ - Retrieves information about the acting user's account, such as billing - information. This is intended to be called off of the :any:`LinodeClient` - class, like this:: - - account = client.account() - - API Documentation: https://www.linode.com/docs/api/account/#account-view - - :returns: Returns the acting user's account information. - :rtype: Account - """ - result = self.client.get("/account") - - if not "email" in result: - raise UnexpectedResponseError( - "Unexpected response when getting account!", json=result - ) - - return Account(self.client, result["email"], result) - - def events(self, *filters): - """ - Lists events on the current account matching the given filters. - - API Documentation: https://www.linode.com/docs/api/account/#events-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of events on the current account matching the given filters. - :rtype: PaginatedList of Event - """ - - return self.client._get_and_filter(Event, *filters) - - def events_mark_seen(self, event): - """ - Marks event as the last event we have seen. If event is an int, it is treated - as an event_id, otherwise it should be an event object whose id will be used. - - API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen - - :param event: The Linode event to mark as seen. - :type event: Event or int - """ - last_seen = event if isinstance(event, int) else event.id - self.client.post( - "{}/seen".format(Event.api_endpoint), - model=Event(self.client, last_seen), - ) - - def settings(self): - """ - Returns the account settings data for this acocunt. This is not a - listing endpoint. - - API Documentation: https://www.linode.com/docs/api/account/#account-settings-view - - :returns: The account settings data for this account. - :rtype: AccountSettings - """ - result = self.client.get("/account/settings") - - if not "managed" in result: - raise UnexpectedResponseError( - "Unexpected response when getting account settings!", - json=result, - ) - - s = AccountSettings(self.client, result["managed"], result) - return s - - def invoices(self): - """ - Returns Invoices issued to this account. - - API Documentation: https://www.linode.com/docs/api/account/#invoices-list - - :param filters: Any number of filters to apply to this query. - - :returns: Invoices issued to this account. - :rtype: PaginatedList of Invoice - """ - return self.client._get_and_filter(Invoice) - - def payments(self): - """ - Returns a list of Payments made on this account. - - API Documentation: https://www.linode.com/docs/api/account/#payments-list - - :returns: A list of payments made on this account. - :rtype: PaginatedList of Payment - """ - return self.client._get_and_filter(Payment) - - def oauth_clients(self, *filters): - """ - Returns the OAuth Clients associated with this account. - - API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of OAuth Clients associated with this account. - :rtype: PaginatedList of OAuthClient - """ - return self.client._get_and_filter(OAuthClient, *filters) - - def oauth_client_create(self, name, redirect_uri, **kwargs): - """ - Creates a new OAuth client. - - API Documentation: https://www.linode.com/docs/api/account/#oauth-client-create - - :param name: The name of this application. - :type name: str - :param redirect_uri: The location a successful log in from https://login.linode.com should be redirected to for this client. - :type redirect_uri: str - - :returns: The created OAuth Client. - :rtype: OAuthClient - """ - params = { - "label": name, - "redirect_uri": redirect_uri, - } - params.update(kwargs) - - result = self.client.post("/account/oauth-clients", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating OAuth Client!", json=result - ) - - c = OAuthClient(self.client, result["id"], result) - return c - - def users(self, *filters): - """ - Returns a list of users on this account. - - API Documentation: https://www.linode.com/docs/api/account/#users-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of users on this account. - :rtype: PaginatedList of User - """ - return self.client._get_and_filter(User, *filters) - - def logins(self): - """ - Returns a collection of successful logins for all users on the account during the last 90 days. - - API Documentation: https://www.linode.com/docs/api/account/#user-logins-list-all - - :returns: A list of Logins on this account. - :rtype: PaginatedList of Login - """ - - return self.client._get_and_filter(Login) - - 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://www.linode.com/docs/api/account/#user-logins-list-all - - :returns: A list of Maintenance objects on this account. - :rtype: List of Maintenance objects as MappedObjects - """ - - result = self.client.get( - "{}/maintenance".format(Account.api_endpoint), model=self - ) - - return [MappedObject(**r) for r in result["data"]] - - def payment_methods(self): - """ - Returns a list of Payment Methods for this Account. - - API Documentation: https://www.linode.com/docs/api/account/#payment-methods-list - - :returns: A list of Payment Methods on this account. - :rtype: PaginatedList of PaymentMethod - """ - - return self.client._get_and_filter(PaymentMethod) - - def add_payment_method(self, data, is_default, type): - """ - Adds a Payment Method to your Account with the option to set it as the default method. - - API Documentation: https://www.linode.com/docs/api/account/#payment-method-add - - :param data: An object representing the credit card information you have on file with - Linode to make Payments against your Account. - :type data: dict - - Example usage:: - data = { - "card_number": "4111111111111111", - "expiry_month": 11, - "expiry_year": 2020, - "cvv": "111" - } - - :param is_default: Whether this Payment Method is the default method for - automatically processing service charges. - :type is_default: bool - - :param type: The type of Payment Method. Enum: ["credit_card] - :type type: str - """ - - if type != "credit_card": - raise ValueError("Unknown Payment Method type: {}".format(type)) - - if ( - "card_number" not in data - or "expiry_month" not in data - or "expiry_year" not in data - or "cvv" not in data - or not data - ): - raise ValueError("Invalid credit card info provided") - - params = {"data": data, "type": type, "is_default": is_default} - - resp = self.client.post( - "{}/payment-methods".format(Account.api_endpoint), - model=self, - data=params, - ) - - if "error" in resp: - raise UnexpectedResponseError( - "Unexpected response when adding payment method!", - json=resp, - ) - - def notifications(self): - """ - Returns a collection of Notification objects representing important, often time-sensitive items related to your Account. - - API Documentation: https://www.linode.com/docs/api/account/#notifications-list - - :returns: A list of Notifications on this account. - :rtype: List of Notification objects as MappedObjects - """ - - result = self.client.get( - "{}/notifications".format(Account.api_endpoint), model=self - ) - - return [MappedObject(**r) for r in result["data"]] - - def linode_managed_enable(self): - """ - Enables Linode Managed for the entire account and sends a welcome email to the account’s associated email address. - - API Documentation: https://www.linode.com/docs/api/account/#linode-managed-enable - """ - - resp = self.client.post( - "{}/settings/managed-enable".format(Account.api_endpoint), - model=self, - ) - - if "error" in resp: - raise UnexpectedResponseError( - "Unexpected response when enabling Linode Managed!", - json=resp, - ) - - def add_promo_code(self, promo_code): - """ - Adds an expiring Promo Credit to your account. - - API Documentation: https://www.linode.com/docs/api/account/#promo-credit-add - - :param promo_code: The Promo Code. - :type promo_code: str - """ - - params = { - "promo_code": promo_code, - } - - resp = self.client.post( - "{}/promo-codes".format(Account.api_endpoint), - model=self, - data=params, - ) - - if "error" in resp: - raise UnexpectedResponseError( - "Unexpected response when adding Promo Code!", - json=resp, - ) - - def service_transfers(self): - """ - Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer. - - API Documentation: https://www.linode.com/docs/api/account/#service-transfers-list - - :returns: A list of Service Transfers on this account. - :rtype: PaginatedList of ServiceTransfer - """ - - return self.client._get_and_filter(ServiceTransfer) - - def service_transfer_create(self, entities): - """ - Creates a transfer request for the specified services. - - API Documentation: https://www.linode.com/docs/api/account/#service-transfer-create - - :param entities: A collection of the services to include in this transfer request, separated by type. - :type entities: dict - - Example usage:: - entities = { - "linodes": [ - 111, - 222 - ] - } - """ - - if not entities: - raise ValueError("Entities must be provided!") - - bad_entries = [ - k for k, v in entities.items() if not isinstance(v, list) - ] - if len(bad_entries) > 0: - raise ValueError( - f"Got unexpected type for entity lists: {', '.join(bad_entries)}" - ) - - params = {"entities": entities} - - resp = self.client.post( - "{}/service-transfers".format(Account.api_endpoint), - model=self, - data=params, - ) - - if "error" in resp: - raise UnexpectedResponseError( - "Unexpected response when creating Service Transfer!", - json=resp, - ) - - def transfer(self): - """ - Returns a MappedObject containing the account's transfer pool data. - - API Documentation: https://www.linode.com/docs/api/account/#network-utilization-view - - :returns: Information about this account's transfer pool data. - :rtype: MappedObject - """ - result = self.client.get("/account/transfer") - - if not "used" in result: - raise UnexpectedResponseError( - "Unexpected response when getting Transfer Pool!" - ) - - return MappedObject(**result) - - def user_create(self, email, username, restricted=True): - """ - Creates a new user on your account. If you create an unrestricted user, - they will immediately be able to access everything on your account. If - you create a restricted user, you must grant them access to parts of your - account that you want to allow them to manage (see :any:`User.grants` for - details). - - The new user will receive an email inviting them to set up their password. - This must be completed before they can log in. - - API Documentation: https://www.linode.com/docs/api/account/#user-create - - :param email: The new user's email address. This is used to finish setting - up their user account. - :type email: str - :param username: The new user's unique username. They will use this username - to log in. - :type username: str - :param restricted: If True, the new user must be granted access to parts of - the account before they can do anything. If False, the - new user will immediately be able to manage the entire - account. Defaults to True. - :type restricted: True - - :returns The new User. - :rtype: User - """ - params = { - "email": email, - "username": username, - "restricted": restricted, - } - result = self.client.post("/account/users", data=params) - - if not all( - [c in result for c in ("email", "restricted", "username")] - ): # pylint: disable=use-a-generator - raise UnexpectedResponseError( - "Unexpected response when creating user!", json=result - ) - - u = User(self.client, result["username"], result) - return u - - -class NetworkingGroup(Group): - """ - Collections related to Linode Networking. - """ - - def firewalls(self, *filters): - """ - Retrieves the Firewalls your user has access to. - - API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Firewalls the acting user can access. - :rtype: PaginatedList of Firewall - """ - return self.client._get_and_filter(Firewall, *filters) - - def firewall_create(self, label, rules, **kwargs): - """ - Creates a new Firewall, either in the given Region or - attached to the given Instance. - - API Documentation: https://www.linode.com/docs/api/networking/#firewall-create - - :param label: The label for the new Firewall. - :type label: str - :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. - :type rules: dict - - :returns: The new Firewall. - :rtype: Firewall - - Example usage:: - - rules = { - 'outbound': [ - { - 'action': 'ACCEPT', - 'addresses': { - 'ipv4': [ - '0.0.0.0/0' - ], - 'ipv6': [ - "ff00::/8" - ] - }, - 'description': 'Allow HTTP out.', - 'label': 'allow-http-out', - 'ports': '80', - 'protocol': 'TCP' - } - ], - 'outbound_policy': 'DROP', - 'inbound': [], - 'inbound_policy': 'DROP' - } - - firewall = client.networking.firewall_create('my-firewall', rules) - - .. _Firewalls Documentation: https://www.linode.com/docs/api/networking/#firewall-create__request-body-schema - """ - - params = { - "label": label, - "rules": rules, - } - params.update(kwargs) - - result = self.client.post("/networking/firewalls", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Firewall!", json=result - ) - - f = Firewall(self.client, result["id"], result) - return f - - def ips(self, *filters): - """ - Returns a list of IP addresses on this account, excluding private addresses. - - API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of IP addresses on this account. - :rtype: PaginatedList of IPAddress - """ - return self.client._get_and_filter(IPAddress, *filters) - - def ipv6_ranges(self, *filters): - """ - Returns a list of IPv6 ranges on this account. - - API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of IPv6 ranges on this account. - :rtype: PaginatedList of IPv6Range - """ - return self.client._get_and_filter(IPv6Range, *filters) - - def ipv6_pools(self, *filters): - """ - Returns a list of IPv6 pools on this account. - - API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of IPv6 pools on this account. - :rtype: PaginatedList of IPv6Pool - """ - - return self.client._get_and_filter(IPv6Pool, *filters) - - def vlans(self, *filters): - """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - - Returns a list of VLANs on your account. - - API Documentation: https://www.linode.com/docs/api/networking/#vlans-list - - :param filters: Any number of filters to apply to this query. - - :returns: A List of VLANs on your account. - :rtype: PaginatedList of VLAN - """ - return self.client._get_and_filter(VLAN, *filters) - - def ips_assign(self, region, *assignments): - """ - Redistributes :any:`IP Addressees` within a single region. - This function takes a :any:`Region` and a list of assignments to make, - then requests that the assignments take place. If any :any:`Instance` - ends up without a public IP, or with more than one private IP, all of - the assignments will fail. - - .. note:: - This function *does not* update the local Linode Instance objects - when called. In order to see the new addresses on the local - instance objects, be sure to invalidate them with ``invalidate()`` - after this completes. - - Example usage:: - - linode1 = Instance(client, 123) - linode2 = Instance(client, 456) - - # swap IPs between linodes 1 and 2 - client.networking.assign_ips(linode1.region, - linode1.ips.ipv4.public[0].to(linode2), - linode2.ips.ipv4.public[0].to(linode1)) - - # make sure linode1 and linode2 have updated ipv4 and ips values - linode1.invalidate() - linode2.invalidate() - - API Documentation: https://www.linode.com/docs/api/networking/#linodes-assign-ipv4s - - :param region: The Region in which the assignments should take place. - All Instances and IPAddresses involved in the assignment - must be within this region. - :type region: str or Region - :param assignments: Any number of assignments to make. See - :any:`IPAddress.to` for details on how to construct - assignments. - :type assignments: dct - - DEPRECATED: Use ip_addresses_assign() instead - """ - for a in assignments: - if not "address" in a or not "linode_id" in a: - raise ValueError("Invalid assignment: {}".format(a)) - if isinstance(region, Region): - region = region.id - - self.client.post( - "/networking/ipv4/assign", - data={ - "region": region, - "assignments": assignments, - }, - ) - - def ip_allocate(self, linode, public=True): - """ - Allocates an IP to a Instance you own. Additional IPs must be requested - by opening a support ticket first. - - API Documentation: https://www.linode.com/docs/api/networking/#ip-address-allocate - - :param linode: The Instance to allocate the new IP for. - :type linode: Instance or int - :param public: If True, allocate a public IP address. Defaults to True. - :type public: bool - - :returns: The new IPAddress. - :rtype: IPAddress - """ - result = self.client.post( - "/networking/ips/", - data={ - "linode_id": linode.id if isinstance(linode, Base) else linode, - "type": "ipv4", - "public": public, - }, - ) - - if not "address" in result: - raise UnexpectedResponseError( - "Unexpected response when adding IPv4 address!", json=result - ) - - ip = IPAddress(self.client, result["address"], result) - return ip - - def ips_share(self, linode, *ips): - """ - Shares the given list of :any:`IPAddresses` with the provided - :any:`Instance`. This will enable the provided Instance to bring up the - shared IP Addresses even though it does not own them. - - API Documentation: https://www.linode.com/docs/api/networking/#ipv4-sharing-configure - - :param linode: The Instance to share the IPAddresses with. This Instance - will be able to bring up the given addresses. - :type: linode: int or Instance - :param ips: Any number of IPAddresses to share to the Instance. - :type ips: str or IPAddress - - DEPRECATED: Use ip_addresses_share() instead - """ - if not isinstance(linode, Instance): - # make this an object - linode = Instance(self.client, linode) - - params = [] - for ip in ips: - if isinstance(ip, str): - params.append(ip) - elif isinstance(ip, IPAddress): - params.append(ip.address) - else: - params.append(str(ip)) # and hope that works - - params = {"ips": params} - - self.client.post( - "{}/networking/ipv4/share".format(Instance.api_endpoint), - model=linode, - data=params, - ) - - linode.invalidate() # clear the Instance's shared IPs - - def ip_addresses_share(self, ips, linode): - """ - Configure shared IPs. P sharing allows IP address reassignment - (also referred to as IP failover) from one Linode to another if the - primary Linode becomes unresponsive. This means that requests to the primary Linode’s - IP address can be automatically rerouted to secondary Linodes at the configured shared IP addresses. - - :param linode: The id of the Instance or the Instance to share the IPAddresses with. - This Instance will be able to bring up the given addresses. - :type: linode: int or Instance - :param ips: Any number of IPAddresses to share to the Instance. - :type ips: str or IPAddress - """ - - params = { - "ips": ips - if not isinstance(ips[0], IPAddress) - else [ip.address for ip in ips], - "linode_id": linode - if not isinstance(linode, Instance) - else linode.id, - } - - self.client.post("/networking/ips/share", model=self, data=params) - - def ip_addresses_assign(self, assignments, region): - """ - Assign multiple IPv4 addresses and/or IPv6 ranges to multiple Linodes in one Region. - This allows swapping, shuffling, or otherwise reorganizing IPs to your Linodes. - - The following restrictions apply: - - All Linodes involved must have at least one public IPv4 address after assignment. - - Linodes may have no more than one assigned private IPv4 address. - - Linodes may have no more than one assigned IPv6 range. - - - :param region: The Region in which the assignments should take place. - All Instances and IPAddresses involved in the assignment - must be within this region. - :type region: str or Region - :param assignments: Any number of assignments to make. See - :any:`IPAddress.to` for details on how to construct - assignments. - :type assignments: dct - """ - - for a in assignments["assignments"]: - if not "address" in a or not "linode_id" in a: - raise ValueError("Invalid assignment: {}".format(a)) - - if isinstance(region, Region): - region = region.id - - params = {"assignments": assignments, "region": region} - - self.client.post("/networking/ips/assign", model=self, data=params) - - -class SupportGroup(Group): - """ - Collections related to support tickets. - """ - - def tickets(self, *filters): - """ - Returns a list of support tickets on this account. - - API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of support tickets on this account. - :rtype: PaginatedList of SupportTicket - """ - - return self.client._get_and_filter(SupportTicket, *filters) - - def ticket_open( - self, - summary, - description, - managed_issue=False, - regarding=None, - **kwargs, - ): - """ - Opens a support ticket on this account. - - API Documentation: https://www.linode.com/docs/api/support/#support-ticket-open - - :param summary: The summary or title for this support ticket. - :type summary: str - :param description: The full details of the issue or question. - :type description: str - :param regarding: The resource being referred to in this ticket. - :type regarding: - :param managed_issue: Designates if this ticket relates to a managed service. - :type managed_issue: bool - - :returns: The new support ticket. - :rtype: SupportTicket - """ - params = { - "summary": summary, - "description": description, - "managed_issue": managed_issue, - } - - type_to_id = { - Instance: "linode_id", - Domain: "domain_id", - NodeBalancer: "nodebalancer_id", - Volume: "volume_id", - Firewall: "firewall_id", - LKECluster: "lkecluster_id", - Database: "database_id", - LongviewClient: "longviewclient_id", - } - - params.update(kwargs) - - if regarding: - id_attr = type_to_id.get(type(regarding)) - - if id_attr is not None: - params[id_attr] = regarding.id - elif isinstance(regarding, VLAN): - params["vlan"] = regarding.label - params["region"] = regarding.region - else: - raise ValueError( - "Cannot open ticket regarding type {}!".format( - type(regarding) - ) - ) - - result = self.client.post("/support/tickets", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating ticket!", json=result - ) - - t = SupportTicket(self.client, result["id"], result) - return t - - -class ObjectStorageGroup(Group): - """ - This group encapsulates all endpoints under /object-storage, including viewing - available clusters and managing keys. - """ - - def clusters(self, *filters): - """ - Returns a list of available Object Storage Clusters. You may filter - this query to return only Clusters that are available in a specific region:: - - us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") - - API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Object Storage Clusters that matched the query. - :rtype: PaginatedList of ObjectStorageCluster - """ - return self.client._get_and_filter(ObjectStorageCluster, *filters) - - def keys(self, *filters): - """ - Returns a list of Object Storage Keys active on this account. These keys - allow third-party applications to interact directly with Linode Object Storage. - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Object Storage Keys that matched the query. - :rtype: PaginatedList of ObjectStorageKeys - """ - return self.client._get_and_filter(ObjectStorageKeys, *filters) - - def keys_create(self, label, bucket_access=None): - """ - Creates a new Object Storage keypair that may be used to interact directly - with Linode Object Storage in third-party applications. This response is - the only time that "secret_key" will be populated - be sure to capture its - value or it will be lost forever. - - If given, `bucket_access` will cause the new keys to be restricted to only - the specified level of access for the specified buckets. For example, to - create a keypair that can only access the "example" bucket in all clusters - (and assuming you own that bucket in every cluster), you might do this:: - - client = LinodeClient(TOKEN) - - # look up clusters - all_clusters = client.object_storage.clusters() - - new_keys = client.object_storage.keys_create( - "restricted-keys", - bucket_access=[ - client.object_storage.bucket_access(cluster, "example", "read_write") - for cluster in all_clusters - ], - ) - - To create a keypair that can only read from the bucket "example2" in the - "us-east-1" cluster (an assuming you own that bucket in that cluster), - you might do this:: - - client = LinodeClient(TOKEN) - new_keys_2 = client.object_storage.keys_create( - "restricted-keys-2", - bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), - ) - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create - - :param label: The label for this keypair, for identification only. - :type label: str - :param bucket_access: One or a list of dicts with keys "cluster," - "permissions", and "bucket_name". If given, the - resulting Object Storage keys will only have the - requested level of access to the requested buckets, - if they exist and are owned by you. See the provided - :any:`bucket_access` function for a convenient way - to create these dicts. - :type bucket_access: dict or list of dict - - :returns: The new keypair, with the secret key populated. - :rtype: ObjectStorageKeys - """ - params = {"label": label} - - if bucket_access is not None: - if not isinstance(bucket_access, list): - bucket_access = [bucket_access] - - ba = [ - { - "permissions": c.get("permissions"), - "bucket_name": c.get("bucket_name"), - "cluster": c.id - if "cluster" in c and issubclass(type(c["cluster"]), Base) - else c.get("cluster"), - } - for c in bucket_access - ] - - params["bucket_access"] = ba - - result = self.client.post("/object-storage/keys", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Object Storage Keys!", - json=result, - ) - - ret = ObjectStorageKeys(self.client, result["id"], result) - return ret - - def bucket_access(self, cluster, bucket_name, permissions): - """ - Returns a dict formatted to be included in the `bucket_access` argument - of :any:`keys_create`. See the docs for that method for an example of - usage. - - :param cluster: The Object Storage cluster to grant access in. - :type cluster: :any:`ObjectStorageCluster` or str - :param bucket_name: The name of the bucket to grant access to. - :type bucket_name: str - :param permissions: The permissions to grant. Should be one of "read_only" - or "read_write". - :type permissions: str - - :returns: A dict formatted correctly for specifying bucket access for - new keys. - :rtype: dict - """ - return { - "cluster": cluster, - "bucket_name": bucket_name, - "permissions": permissions, - } - - def cancel(self): - """ - Cancels Object Storage service. This may be a destructive operation. Once - cancelled, you will no longer receive the transfer for or be billed for - Object Storage, and all keys will be invalidated. - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel - """ - self.client.post("/object-storage/cancel", data={}) - return True - - -class DatabaseGroup(Group): - """ - Encapsulates Linode Managed Databases related methods of the :any:`LinodeClient`. This - should not be instantiated on its own, but should instead be used through - an instance of :any:`LinodeClient`:: - - client = LinodeClient(token) - instances = client.database.instances() # use the DatabaseGroup - - This group contains all features beneath the `/databases` group in the API v4. - """ - - def types(self, *filters): - """ - Returns a list of Linode Database-compatible Instance types. - These may be used to create Managed Databases, or simply - referenced to on their own. DatabaseTypes can be - filtered to return specific types, for example:: - - database_types = client.database.types(DatabaseType.deprecated == False) - - API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list - - :param filters: Any number of filters to apply to the query. - - :returns: A list of types that match the query. - :rtype: PaginatedList of DatabaseType - """ - return self.client._get_and_filter(DatabaseType, *filters) - - def engines(self, *filters): - """ - Returns a list of Linode Managed Database Engines. - These may be used to create Managed Databases, or simply - referenced to on their own. Engines can be filtered to - return specific engines, for example:: - - mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql') - - API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list - - :param filters: Any number of filters to apply to the query. - - :returns: A list of types that match the query. - :rtype: PaginatedList of DatabaseEngine - """ - return self.client._get_and_filter(DatabaseEngine, *filters) - - def instances(self, *filters): - """ - Returns a list of Managed Databases active on this account. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all - - :param filters: Any number of filters to apply to this query. - - :returns: A list of databases that matched the query. - :rtype: PaginatedList of Database - """ - return self.client._get_and_filter(Database, *filters) - - def mysql_instances(self, *filters): - """ - Returns a list of Managed MySQL Databases active on this account. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of MySQL databases that matched the query. - :rtype: PaginatedList of MySQLDatabase - """ - return self.client._get_and_filter(MySQLDatabase, *filters) - - def mysql_create(self, label, region, engine, ltype, **kwargs): - """ - Creates an :any:`MySQLDatabase` on this account with - the given label, region, engine, and node type. For example:: - - client = LinodeClient(TOKEN) - - # look up Region and Types to use. In this example I'm just using - # the first ones returned. - region = client.regions().first() - node_type = client.database.types()[0] - engine = client.database.engines(DatabaseEngine.engine == 'mysql')[0] - - new_database = client.database.mysql_create( - "example-database", - region, - engine.id, - type.id - ) - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-create - - :param label: The name for this cluster - :type label: str - :param region: The region to deploy this cluster in - :type region: str or Region - :param engine: The engine to deploy this cluster with - :type engine: str or Engine - :param ltype: The Linode Type to use for this cluster - :type ltype: str or Type - """ - - params = { - "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - } - params.update(kwargs) - - result = self.client.post("/databases/mysql/instances", data=params) - - if "id" not in result: - raise UnexpectedResponseError( - "Unexpected response when creating MySQL Database", json=result - ) - - d = MySQLDatabase(self.client, result["id"], result) - return d - - def postgresql_instances(self, *filters): - """ - Returns a list of Managed PostgreSQL Databases active on this account. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of PostgreSQL databases that matched the query. - :rtype: PaginatedList of PostgreSQLDatabase - """ - return self.client._get_and_filter(PostgreSQLDatabase, *filters) - - def postgresql_create(self, label, region, engine, ltype, **kwargs): - """ - Creates an :any:`PostgreSQLDatabase` on this account with - the given label, region, engine, and node type. For example:: - - client = LinodeClient(TOKEN) - - # look up Region and Types to use. In this example I'm just using - # the first ones returned. - region = client.regions().first() - node_type = client.database.types()[0] - engine = client.database.engines(DatabaseEngine.engine == 'postgresql')[0] - - new_database = client.database.postgresql_create( - "example-database", - region, - engine.id, - type.id - ) - - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-create - - :param label: The name for this cluster - :type label: str - :param region: The region to deploy this cluster in - :type region: str or Region - :param engine: The engine to deploy this cluster with - :type engine: str or Engine - :param ltype: The Linode Type to use for this cluster - :type ltype: str or Type - """ - - params = { - "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - } - params.update(kwargs) - - result = self.client.post( - "/databases/postgresql/instances", data=params - ) - - if "id" not in result: - raise UnexpectedResponseError( - "Unexpected response when creating PostgreSQL Database", - json=result, - ) - - d = PostgreSQLDatabase(self.client, result["id"], result) - return d - - def mongodb_instances(self, *filters): - """ - Returns a list of Managed MongoDB Databases active on this account. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-databases-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of MongoDB databases that matched the query. - :rtype: PaginatedList of MongoDBDatabase - """ - return self.client._get_and_filter(MongoDBDatabase, *filters) - - def mongodb_create(self, label, region, engine, ltype, **kwargs): - """ - Creates an :any:`MongoDBDatabase` on this account with - the given label, region, engine, and node type. For example:: - - client = LinodeClient(TOKEN) - - # look up Region and Types to use. In this example I'm just using - # the first ones returned. - region = client.regions().first() - node_type = client.database.types()[0] - engine = client.database.engines(DatabaseEngine.engine == 'mongodb')[0] - - new_database = client.database.mongodb_create( - "example-database", - region, - engine.id, - type.id - ) - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-create - - :param label: The name for this cluster - :type label: str - :param region: The region to deploy this cluster in - :type region: str or Region - :param engine: The engine to deploy this cluster with - :type engine: str or Engine - :param ltype: The Linode Type to use for this cluster - :type ltype: str or Type - """ - - params = { - "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - } - params.update(kwargs) - - result = self.client.post("/databases/mongodb/instances", data=params) - - if "id" not in result: - raise UnexpectedResponseError( - "Unexpected response when creating MongoDB Database", - json=result, - ) - - d = MongoDBDatabase(self.client, result["id"], result) - return d - - class LinodeClient: def __init__( self, @@ -2041,6 +106,24 @@ def __init__( #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. self.database = DatabaseGroup(self) + #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. + self.nodebalancers = NodeBalancerGroup(self) + + #: Access methods related to Domains - see :any:`DomainGroup` for more information. + self.domains = DomainGroup(self) + + #: Access methods related to Tags - See :any:`TagGroup` for more information. + self.tags = TagGroup(self) + + #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. + self.volumes = VolumeGroup(self) + + #: Access methods related to Regions - See :any:`RegionGroup` for more information. + self.regions = RegionGroup(self) + + #: Access methods related to Images - See :any:`ImageGroup` for more information. + self.images = ImageGroup(self) + @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( @@ -2205,244 +288,49 @@ def put(self, *args, **kwargs): def delete(self, *args, **kwargs): return self._api_call(*args, method=self.session.delete, **kwargs) - # ungrouped list functions - def regions(self, *filters): - """ - Returns the available Regions for Linode products. - - API Documentation: https://www.linode.com/docs/api/regions/#regions-list - - :param filters: Any number of filters to apply to the query. - - :returns: A list of available Regions. - :rtype: PaginatedList of Region - """ - return self._get_and_filter(Region, *filters) - - def images(self, *filters): - """ - Retrieves a list of available Images, including public and private - Images available to the acting user. You can filter this query to - retrieve only Images relevant to a specific query, for example:: - - debian_images = client.images( - Image.vendor == "debain") - - API Documentation: https://www.linode.com/docs/api/images/#images-list - - :param filters: Any number of filters to apply to the query. - - :returns: A list of available Images. - :rtype: PaginatedList of Image - """ - return self._get_and_filter(Image, *filters) - def image_create(self, disk, label=None, description=None): """ - Creates a new Image from a disk you own. - - API Documentation: https://www.linode.com/docs/api/images/#image-create - - :param disk: The Disk to imagize. - :type disk: Disk or int - :param label: The label for the resulting Image (defaults to the disk's - label. - :type label: str - :param description: The description for the new Image. - :type description: str - - :returns: The new Image. - :rtype: Image + .. note:: This method is an alias to maintain backwards compatibility. + Please use :meth:`LinodeClient.images.create(...) <.ImageGroup.create>` for all new projects. """ - params = { - "disk_id": disk.id if issubclass(type(disk), Base) else disk, - } - - if label is not None: - params["label"] = label - - if description is not None: - params["description"] = description - - result = self.post("/images", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating an Image from disk {}".format( - disk - ) - ) - - return Image(self, result["id"], result) + return self.images.create(disk, label=label, description=description) def image_create_upload( self, label: str, region: str, description: str = None ) -> Tuple[Image, str]: """ - Creates a new Image and returns the corresponding upload URL. - - API Documentation: https://www.linode.com/docs/api/images/#image-upload - - :param label: The label of the Image to create. - :type label: str - :param region: The region to upload to. Once the image has been created, it can be used in any region. - :type region: str - :param description: The description for the new Image. - :type description: str - - :returns: A tuple containing the new image and the image upload URL. - :rtype: (Image, str) + .. note:: This method is an alias to maintain backwards compatibility. + Please use :meth:`LinodeClient.images.create_upload(...) <.ImageGroup.create_upload>` + for all new projects. """ - params = {"label": label, "region": region, "description": description} - - result = self.post("/images/upload", data=drop_null_keys(params)) - - if "image" not in result: - raise UnexpectedResponseError( - "Unexpected response when creating an Image upload URL" - ) - - result_image = result["image"] - result_url = result["upload_to"] - return Image(self, result_image["id"], result_image), result_url + return self.images.create_upload(label, region, description=description) def image_upload( self, label: str, region: str, file: BinaryIO, description: str = None ) -> Image: """ - Creates and uploads a new image. - - API Documentation: https://www.linode.com/docs/api/images/#image-upload - - :param label: The label of the Image to create. - :type label: str - :param region: The region to upload to. Once the image has been created, it can be used in any region. - :type region: str - :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb"). - :param description: The description for the new Image. - :type description: str - - :returns: The resulting image. - :rtype: Image - """ - - image, url = self.image_create_upload( - label, region, description=description - ) - - requests.put( - url, - headers={"Content-Type": "application/octet-stream"}, - data=file, - ) - - image._api_get() - - return image - - def domains(self, *filters): - """ - Retrieves all of the Domains the acting user has access to. - - API Documentation: https://www.linode.com/docs/api/domains/#domains-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Domains the acting user can access. - :rtype: PaginatedList of Domain - """ - return self._get_and_filter(Domain, *filters) - - def nodebalancers(self, *filters): - """ - Retrieves all of the NodeBalancers the acting user has access to. - - API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of NodeBalancers the acting user can access. - :rtype: PaginatedList of NodeBalancers + .. note:: This method is an alias to maintain backwards compatibility. + Please use :meth:`LinodeClient.images.upload(...) <.ImageGroup.upload>` for all new projects. """ - return self._get_and_filter(NodeBalancer, *filters) + return self.images.upload(label, region, file, description=description) def nodebalancer_create(self, region, **kwargs): """ - Creates a new NodeBalancer in the given Region. - - API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-create - - :param region: The Region in which to create the NodeBalancer. - :type region: Region or str - - :returns: The new NodeBalancer - :rtype: NodeBalancer + .. note:: This method is an alias to maintain backwards compatibility. + Please use + :meth:`LinodeClient.nodebalancers.create(...) <.NodeBalancerGroup.create>` + for all new projects. """ - params = { - "region": region.id if isinstance(region, Base) else region, - } - params.update(kwargs) - - result = self.post("/nodebalancers", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Nodebalaner!", json=result - ) - - n = NodeBalancer(self, result["id"], result) - return n + return self.nodebalancers.create(region, **kwargs) def domain_create(self, domain, master=True, **kwargs): """ - Registers a new Domain on the acting user's account. Make sure to point - your registrar to Linode's nameservers so that Linode's DNS manager will - correctly serve your domain. - - API Documentation: https://www.linode.com/docs/api/domains/#domain-create - - :param domain: The domain to register to Linode's DNS manager. - :type domain: str - :param master: Whether this is a master (defaults to true) - :type master: bool - :param tags: A list of tags to apply to the new domain. If any of the - tags included do not exist, they will be created as part of - this operation. - :type tags: list[str] - - :returns: The new Domain object. - :rtype: Domain - """ - params = { - "domain": domain, - "type": "master" if master else "slave", - } - params.update(kwargs) - - result = self.post("/domains", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Domain!", json=result - ) - - d = Domain(self, result["id"], result) - return d - - def tags(self, *filters): - """ - Retrieves the Tags on your account. This may only be attempted by - unrestricted users. - - API Documentation: https://www.linode.com/docs/api/domains/#domain-create - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Tags on the account. - :rtype: PaginatedList of Tag + .. note:: This method is an alias to maintain backwards compatibility. + Please use :meth:`LinodeClient.domains.create(...) <.DomainGroup.create>` for all + new projects. """ - return self._get_and_filter(Tag, *filters) + return self.domains.create(domain, master=master, **kwargs) def tag_create( self, @@ -2454,146 +342,26 @@ def tag_create( entities=[], ): """ - Creates a new Tag and optionally applies it to the given entities. - - API Documentation: https://www.linode.com/docs/api/tags/#tags-list - - :param label: The label for the new Tag - :type label: str - :param entities: A list of objects to apply this Tag to upon creation. - May only be taggable types (Linode Instances, Domains, - NodeBalancers, or Volumes). These are applied *in addition - to* any IDs specified with ``instances``, ``domains``, - ``nodebalancers``, or ``volumes``, and is a convenience - for sending multiple entity types without sorting them - yourself. - :type entities: list of Instance, Domain, NodeBalancer, and/or Volume - :param instances: A list of Linode Instances to apply this Tag to upon - creation - :type instances: list of Instance or list of int - :param domains: A list of Domains to apply this Tag to upon - creation - :type domains: list of Domain or list of int - :param nodebalancers: A list of NodeBalancers to apply this Tag to upon - creation - :type nodebalancers: list of NodeBalancer or list of int - :param volumes: A list of Volumes to apply this Tag to upon - creation - :type volumes: list of Volumes or list of int - - :returns: The new Tag - :rtype: Tag + .. note:: This method is an alias to maintain backwards compatibility. + Please use :meth:`LinodeClient.tags.create(...) <.TagGroup.create>` for all new projects. """ - linode_ids, nodebalancer_ids, domain_ids, volume_ids = [], [], [], [] - - # filter input into lists of ids - sorter = zip( - (linode_ids, nodebalancer_ids, domain_ids, volume_ids), - (instances, nodebalancers, domains, volumes), + return self.tags.create( + label, + instances=instances, + domains=domains, + nodebalancers=nodebalancers, + volumes=volumes, + entities=entities, ) - for id_list, input_list in sorter: - # if we got something, we need to find its ID - if input_list is not None: - for cur in input_list: - if isinstance(cur, int): - id_list.append(cur) - else: - id_list.append(cur.id) - - # filter entities into id lists too - type_map = { - Instance: linode_ids, - NodeBalancer: nodebalancer_ids, - Domain: domain_ids, - Volume: volume_ids, - } - - for e in entities: - if type(e) in type_map: - type_map[type(e)].append(e.id) - else: - raise ValueError("Unsupported entity type {}".format(type(e))) - - # finally, omit all id lists that are empty - params = { - "label": label, - "linodes": linode_ids or None, - "nodebalancers": nodebalancer_ids or None, - "domains": domain_ids or None, - "volumes": volume_ids or None, - } - - result = self.post("/tags", data=params) - - if not "label" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Tag!", json=result - ) - - t = Tag(self, result["label"], result) - return t - - def volumes(self, *filters): - """ - Retrieves the Block Storage Volumes your user has access to. - - API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of Volumes the acting user can access. - :rtype: PaginatedList of Volume - """ - return self._get_and_filter(Volume, *filters) - def volume_create(self, label, region=None, linode=None, size=20, **kwargs): """ - Creates a new Block Storage Volume, either in the given Region or - attached to the given Instance. - - API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list - - :param label: The label for the new Volume. - :type label: str - :param region: The Region to create this Volume in. Not required if - `linode` is provided. - :type region: Region or str - :param linode: The Instance to attach this Volume to. If not given, the - new Volume will not be attached to anything. - :type linode: Instance or int - :param size: The size, in GB, of the new Volume. Defaults to 20. - :type size: int - :param tags: A list of tags to apply to the new volume. If any of the - tags included do not exist, they will be created as part of - this operation. - :type tags: list[str] - - :returns: The new Volume. - :rtype: Volume + .. note:: This method is an alias to maintain backwards compatibility. + Please use :meth:`LinodeClient.volumes.create(...) <.VolumeGroup.create>` for all new projects. """ - if not (region or linode): - raise ValueError("region or linode required!") - - params = { - "label": label, - "size": size, - "region": region.id if issubclass(type(region), Base) else region, - "linode_id": linode.id - if issubclass(type(linode), Base) - else linode, - } - params.update(kwargs) - - result = self.post("/volumes", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating volume!", json=result - ) - - v = Volume(self, result["id"], result) - return v + return self.volumes.create( + label, region=region, linode=linode, size=size, **kwargs + ) # helper functions def _get_and_filter(self, obj_type, *filters): From 2f20ac0960d1f285e98241e93ad1aa11b136a84e Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Fri, 21 Apr 2023 13:22:10 -0400 Subject: [PATCH 083/379] Added `label` field to class `Region`. (#254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added `label` field to class `Region`. ## ✔️ How to Test `tox` Ticket: TPT-1894 --- linode_api4/objects/region.py | 1 + test/fixtures/regions.json | 33 ++++++++++++++++++++++----------- test/objects/region_test.py | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 test/objects/region_test.py diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 300ed7e62..8e127a760 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -9,4 +9,5 @@ class Region(Base): "capabilities": Property(), "status": Property(), "resolvers": Property(), + "label": Property(), } diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index 8ad551718..ab848b3f8 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -12,7 +12,8 @@ "resolvers": { "ipv4": "172.105.34.5,172.105.35.5,172.105.36.5,172.105.37.5,172.105.38.5,172.105.39.5,172.105.40.5,172.105.41.5,172.105.42.5,172.105.43.5", "ipv6": "2400:8904::f03c:91ff:fea5:659,2400:8904::f03c:91ff:fea5:9282,2400:8904::f03c:91ff:fea5:b9b3,2400:8904::f03c:91ff:fea5:925a,2400:8904::f03c:91ff:fea5:22cb,2400:8904::f03c:91ff:fea5:227a,2400:8904::f03c:91ff:fea5:924c,2400:8904::f03c:91ff:fea5:f7e2,2400:8904::f03c:91ff:fea5:2205,2400:8904::f03c:91ff:fea5:9207" - } + }, + "label": "label1" }, { "id": "ca-central", @@ -26,7 +27,8 @@ "resolvers": { "ipv4": "172.105.0.5,172.105.3.5,172.105.4.5,172.105.5.5,172.105.6.5,172.105.7.5,172.105.8.5,172.105.9.5,172.105.10.5,172.105.11.5", "ipv6": "2600:3c04::f03c:91ff:fea9:f63,2600:3c04::f03c:91ff:fea9:f6d,2600:3c04::f03c:91ff:fea9:f80,2600:3c04::f03c:91ff:fea9:f0f,2600:3c04::f03c:91ff:fea9:f99,2600:3c04::f03c:91ff:fea9:fbd,2600:3c04::f03c:91ff:fea9:fdd,2600:3c04::f03c:91ff:fea9:fe2,2600:3c04::f03c:91ff:fea9:f68,2600:3c04::f03c:91ff:fea9:f4a" - } + }, + "label": "label2" }, { "id": "ap-southeast", @@ -40,7 +42,8 @@ "resolvers": { "ipv4": "172.105.166.5,172.105.169.5,172.105.168.5,172.105.172.5,172.105.162.5,172.105.170.5,172.105.167.5,172.105.171.5,172.105.181.5,172.105.161.5", "ipv6": "2400:8907::f03c:92ff:fe6e:ec8,2400:8907::f03c:92ff:fe6e:98e4,2400:8907::f03c:92ff:fe6e:1c58,2400:8907::f03c:92ff:fe6e:c299,2400:8907::f03c:92ff:fe6e:c210,2400:8907::f03c:92ff:fe6e:c219,2400:8907::f03c:92ff:fe6e:1c5c,2400:8907::f03c:92ff:fe6e:c24e,2400:8907::f03c:92ff:fe6e:e6b,2400:8907::f03c:92ff:fe6e:e3d" - } + }, + "label": "label3" }, { "id": "us-central", @@ -54,7 +57,8 @@ "resolvers": { "ipv4": "72.14.179.5,72.14.188.5,173.255.199.5,66.228.53.5,96.126.122.5,96.126.124.5,96.126.127.5,198.58.107.5,198.58.111.5,23.239.24.5", "ipv6": "2600:3c00::2,2600:3c00::9,2600:3c00::7,2600:3c00::5,2600:3c00::3,2600:3c00::8,2600:3c00::6,2600:3c00::4,2600:3c00::c,2600:3c00::b" - } + }, + "label": "label4" }, { "id": "us-west", @@ -68,7 +72,8 @@ "resolvers": { "ipv4": "173.230.145.5,173.230.147.5,173.230.155.5,173.255.212.5,173.255.219.5,173.255.241.5,173.255.243.5,173.255.244.5,74.207.241.5,74.207.242.5", "ipv6": "2600:3c01::2,2600:3c01::9,2600:3c01::5,2600:3c01::7,2600:3c01::3,2600:3c01::8,2600:3c01::4,2600:3c01::b,2600:3c01::c,2600:3c01::6" - } + }, + "label": "label5" }, { "id": "us-southeast", @@ -82,7 +87,8 @@ "resolvers": { "ipv4": "74.207.231.5,173.230.128.5,173.230.129.5,173.230.136.5,173.230.140.5,66.228.59.5,66.228.62.5,50.116.35.5,50.116.41.5,23.239.18.5", "ipv6": "2600:3c02::3,2600:3c02::5,2600:3c02::4,2600:3c02::6,2600:3c02::c,2600:3c02::7,2600:3c02::2,2600:3c02::9,2600:3c02::8,2600:3c02::b" - } + }, + "label": "label6" }, { "id": "us-east", @@ -97,7 +103,8 @@ "resolvers": { "ipv4": "66.228.42.5,96.126.106.5,50.116.53.5,50.116.58.5,50.116.61.5,50.116.62.5,66.175.211.5,97.107.133.4,207.192.69.4,207.192.69.5", "ipv6": "2600:3c03::7,2600:3c03::4,2600:3c03::9,2600:3c03::6,2600:3c03::3,2600:3c03::c,2600:3c03::5,2600:3c03::b,2600:3c03::2,2600:3c03::8" - } + }, + "label": "label7" }, { "id": "eu-west", @@ -111,7 +118,8 @@ "resolvers": { "ipv4": "178.79.182.5,176.58.107.5,176.58.116.5,176.58.121.5,151.236.220.5,212.71.252.5,212.71.253.5,109.74.192.20,109.74.193.20,109.74.194.20", "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2" - } + }, + "label": "label8" }, { "id": "ap-south", @@ -126,7 +134,8 @@ "resolvers": { "ipv4": "139.162.11.5,139.162.13.5,139.162.14.5,139.162.15.5,139.162.16.5,139.162.21.5,139.162.27.5,103.3.60.18,103.3.60.19,103.3.60.20", "ipv6": "2400:8901::5,2400:8901::4,2400:8901::b,2400:8901::3,2400:8901::9,2400:8901::2,2400:8901::8,2400:8901::7,2400:8901::c,2400:8901::6" - } + }, + "label": "label9" }, { "id": "eu-central", @@ -141,7 +150,8 @@ "resolvers": { "ipv4": "139.162.130.5,139.162.131.5,139.162.132.5,139.162.133.5,139.162.134.5,139.162.135.5,139.162.136.5,139.162.137.5,139.162.138.5,139.162.139.5", "ipv6": "2a01:7e01::5,2a01:7e01::9,2a01:7e01::7,2a01:7e01::c,2a01:7e01::2,2a01:7e01::4,2a01:7e01::3,2a01:7e01::6,2a01:7e01::b,2a01:7e01::8" - } + }, + "label": "label10" }, { "id": "ap-northeast", @@ -155,7 +165,8 @@ "resolvers": { "ipv4": "139.162.66.5,139.162.67.5,139.162.68.5,139.162.69.5,139.162.70.5,139.162.71.5,139.162.72.5,139.162.73.5,139.162.74.5,139.162.75.5", "ipv6": "2400:8902::3,2400:8902::6,2400:8902::c,2400:8902::4,2400:8902::2,2400:8902::8,2400:8902::7,2400:8902::5,2400:8902::b,2400:8902::9" - } + }, + "label": "label11" } ], "page": 1, diff --git a/test/objects/region_test.py b/test/objects/region_test.py new file mode 100644 index 000000000..3a2cb62d4 --- /dev/null +++ b/test/objects/region_test.py @@ -0,0 +1,22 @@ +from test.base import ClientBaseCase + +from linode_api4.objects import Region + + +class RegionTest(ClientBaseCase): + """ + Tests methods of the Region class + """ + + def test_get_region(self): + """ + Tests that a Region is loaded correctly by ID + """ + region = Region(self.client, "us-east") + + self.assertEqual(region.id, "us-east") + self.assertIsNotNone(region.capabilities) + self.assertEqual(region.country, "us") + self.assertEqual(region.label, "label7") + self.assertEqual(region.status, "ok") + self.assertIsNotNone(region.resolvers) From 18d372be364a630aeb4cd87fd4befd915b99abe0 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Fri, 21 Apr 2023 14:00:35 -0400 Subject: [PATCH 084/379] Add documentation to `objects/linode.py` (#253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation for `linode.py`. ## ✔️ How to Test `tox` Ticket: TPT-1926 --- linode_api4/objects/linode.py | 373 ++++++++++++++++++++++++++++++++-- test/objects/linode_test.py | 4 +- 2 files changed, 362 insertions(+), 15 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 3a9ec9565..b23accc80 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -18,6 +18,12 @@ class Backup(DerivedBase): + """ + A Backup of a Linode Instance. + + View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId}/backups/{backupId} + """ + api_endpoint = "/linode/instances/{linode_id}/backups/{id}" derived_url_path = "backups" parent_id_name = "linode_id" @@ -40,6 +46,30 @@ class Backup(DerivedBase): } def restore_to(self, linode, **kwargs): + """ + Restores a Linode’s Backup to the specified Linode. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups/{backupId}/restore + + :param linode: The id of the Instance or the Instance to share the IPAddresses with. + This Instance will be able to bring up the given addresses. + :type: linode: int or Instance + + :param kwargs: A dict containing the The ID of the Linode to restore a Backup to and + a boolean that, if True, deletes all Disks and Configs on + the target Linode before restoring. + :type: kwargs: dict + + Example usage: + kwargs = { + "linode_id": 123, + "overwrite": true + } + + :returns: Returns true if the operation was successful + :rtype: bool + """ + d = { "linode_id": linode.id if issubclass(type(linode), Base) @@ -54,6 +84,12 @@ def restore_to(self, linode, **kwargs): class Disk(DerivedBase): + """ + A Disk for the storage space on a Compute Instance. + + View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId}/disks/{diskId} + """ + api_endpoint = "/linode/instances/{linode_id}/disks/{id}" derived_url_path = "disks" parent_id_name = "linode_id" @@ -70,6 +106,16 @@ class Disk(DerivedBase): } def duplicate(self): + """ + Copies a disk, byte-for-byte, into a new Disk belonging to the same Linode. The Linode must have enough + storage space available to accept a new Disk of the same size as this one or this operation will fail. + + API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-clone + + :returns: A Disk object representing the cloned Disk + :rtype: Disk + """ + d = self._client.post("{}/clone".format(Disk.api_endpoint), model=self) if not "id" in d: @@ -80,6 +126,15 @@ def duplicate(self): return Disk(self._client, d["id"], self.linode_id) def reset_root_password(self, root_password=None): + """ + Resets the password of a Disk you have permission to read_write. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/disks/{diskId}/password + + :param root_password: The new root password for the OS installed on this Disk. The password must meet the complexity + strength validation requirements for a strong password. + :type: root_password: str + """ rpass = root_password if not rpass: rpass = Instance.generate_root_password() @@ -102,6 +157,8 @@ def resize(self, new_size): fit on the new disk size. You may need to resize the filesystem on the disk first before performing this action. + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/disks/{diskId}/resize + :param new_size: The intended new size of the disk, in MB :type new_size: int @@ -118,6 +175,31 @@ def resize(self, new_size): class Kernel(Base): + """ + The primary component of every Linux system. The kernel interfaces + with the system’s hardware and it controls the operating system’s core functionality. + + Your Compute Instance is capable of running one of three kinds of kernels: + + - Upstream kernel (or distribution-supplied kernel): This kernel is maintained + and provided by your Linux distribution. A major benefit of this kernel is that the + distribution was designed with this kernel in mind and all updates are managed through + the distributions package management system. It also may support features not present + in the Linode kernel (for example, SELinux). + + - Linode kernel: Linode also maintains kernels that can be used on a Compute Instance. + If selected, these kernels are provided to your Compute Instance at boot + (not directly installed on your system). The Current Kernels page displays a + list of all the available Linode kernels. + + - Custom-compiled kernel: A kernel that you compile from source. Compiling a kernel + can let you use features not available in the upstream or Linode kernels, but it takes longer + to compile the kernel from source than to download it from your package manager. For more + information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS. + + View Endpoint: https://api.linode.com/v4/linode/kernels/{kernelId} + """ + api_endpoint = "/linode/kernels/{id}" properties = { "created": Property(is_datetime=True), @@ -136,6 +218,12 @@ class Kernel(Base): class Type(Base): + """ + Linode Plan type to specify the resources available to a Linode Instance. + + View Endpoint: https://api.linode.com/v4/linode/types/{typeId} + """ + api_endpoint = "/linode/types/{id}" properties = { "disk": Property(filterable=True), @@ -207,6 +295,12 @@ def _serialize(self): class Config(DerivedBase): + """ + A Configuration Profile for a Linode Instance. + + View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId}/configs/{configId} + """ + api_endpoint = "/linode/instances/{linode_id}/configs/{id}" derived_url_path = "configs" parent_id_name = "linode_id" @@ -289,6 +383,12 @@ def _serialize(self): class Instance(Base): + """ + A Linode Instance. + + View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId} + """ + api_endpoint = "/linode/instances/{id}" properties = { "id": Property(identifier=True, filterable=True), @@ -318,6 +418,11 @@ def ips(self): """ The ips related collection is not normalized like the others, so we have to make an ad-hoc object to return for its response + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/ips + + :returns: A List of the ips of the Linode Instance. + :rtype: List[IPAddress] """ if not hasattr(self, "_ips"): result = self._client.get( @@ -386,6 +491,11 @@ def ips(self): def available_backups(self): """ The backups response contains what backups are available to be restored. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups + + :returns: A List of the available backups for the Linode Instance. + :rtype: List[Backup] """ if not hasattr(self, "_avail_backups"): result = self._client.get( @@ -437,6 +547,17 @@ def available_backups(self): return self._avail_backups def reset_instance_root_password(self, root_password=None): + """ + Resets the root password for this Linode. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/password + + :param root_password: The root user’s password on this Linode. Linode passwords must + meet a password strength score requirement that is calculated internally + by the API. If the strength requirement is not met, you will receive a + Password does not meet strength requirement error. + :type: root_password: str + """ rpass = root_password if not rpass: rpass = Instance.generate_root_password() @@ -452,6 +573,17 @@ def reset_instance_root_password(self, root_password=None): def transfer_year_month(self, year, month): """ Get per-linode transfer for specified month + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/transfer/{year}/{month} + + :param year: Numeric value representing the year to look up. + :type: year: int + + :param month: Numeric value representing the month to look up. + :type: month: int + + :returns: The network transfer statistics for the specified month. + :rtype: MappedObject """ result = self._client.get( @@ -465,6 +597,11 @@ def transfer_year_month(self, year, month): def transfer(self): """ Get per-linode transfer + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/transfer + + :returns: The network transfer statistics for the current month. + :rtype: MappedObject """ if not hasattr(self, "_transfer"): result = self._client.get( @@ -505,6 +642,25 @@ def invalidate(self): Base.invalidate(self) def boot(self, config=None): + """ + Boots a Linode you have permission to modify. If no parameters are given, a Config + profile will be chosen for this boot based on the following criteria: + + - If there is only one Config profile for this Linode, it will be used. + - If there is more than one Config profile, the last booted config will be used. + - If there is more than one Config profile and none were the last to be booted + (because the Linode was never booted or the last booted config was deleted) + an error will be returned. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/boot + + :param config: The Linode Config ID to boot into. + :type: config: int + + :returns: True if the operation was successful. + :rtype: bool + """ + resp = self._client.post( "{}/boot".format(Instance.api_endpoint), model=self, @@ -516,6 +672,17 @@ def boot(self, config=None): return True def shutdown(self): + """ + Shuts down a Linode you have permission to modify. If any actions + are currently running or queued, those actions must be completed + first before you can initiate a shutdown. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/shutdown + + :returns: True if the operation was successful. + :rtype: bool + """ + resp = self._client.post( "{}/shutdown".format(Instance.api_endpoint), model=self ) @@ -525,6 +692,16 @@ def shutdown(self): return True def reboot(self): + """ + Reboots a Linode you have permission to modify. If any actions are currently running + or queued, those actions must be completed first before you can initiate a reboot. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/reboot + + :returns: True if the operation was successful. + :rtype: bool + """ + resp = self._client.post( "{}/reboot".format(Instance.api_endpoint), model=self ) @@ -533,11 +710,37 @@ def reboot(self): return False return True - def resize(self, new_type, **kwargs): + def resize(self, new_type, allow_auto_disk_resize=True, **kwargs): + """ + Resizes a Linode you have the read_write permission to a different Type. If any + actions are currently running or queued, those actions must be completed first + before you can initiate a resize. Additionally, the following criteria must be + met in order to resize a Linode: + + - The Linode must not have a pending migration. + - Your Account cannot have an outstanding balance. + - The Linode must not have more disk allocation than the new Type allows. + - In that situation, you must first delete or resize the disk to be smaller. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/resize + + :param new_type: The Linode Type or the id representing it. + :type: new_type: Type or int + + :param allow_auto_disk_resize: Automatically resize disks when resizing a Linode. + When resizing down to a smaller plan your Linode’s + data must fit within the smaller disk size. Defaults to true. + :type: allow_auto_disk_resize: bool + + :returns: True if the operation was successful. + :rtype: bool + """ + new_type = new_type.id if issubclass(type(new_type), Base) else new_type params = { "type": new_type, + "allow_auto_disk_resize": allow_auto_disk_resize, } params.update(kwargs) @@ -768,6 +971,11 @@ def enable_backups(self): `Backups Page`_ .. _Backups Page: https://www.linode.com/backups + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups/enable + + :returns: True if the operation was successful. + :rtype: bool """ self._client.post( "{}/backups/enable".format(Instance.api_endpoint), model=self @@ -780,6 +988,11 @@ def cancel_backups(self): Cancels Backups for this Instance. All existing Backups will be lost, including any snapshots that have been taken. This cannot be undone, but Backups can be re-enabled at a later date. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups/cancel + + :returns: True if the operation was successful. + :rtype: bool """ self._client.post( "{}/backups/cancel".format(Instance.api_endpoint), model=self @@ -788,6 +1001,21 @@ def cancel_backups(self): return True def snapshot(self, label=None): + """ + Creates a snapshot Backup of a Linode. + + Important: If you already have a snapshot of this Linode, this + is a destructive action. The previous snapshot will be deleted. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups + + :param label: The label for the new snapshot. + :type: label: str + + :returns: The snapshot Backup created. + :rtype: Backup + """ + result = self._client.post( "{}/backups".format(Instance.api_endpoint), model=self, @@ -890,6 +1118,33 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): return ret_pass def rescue(self, *disks): + """ + Rescue Mode is a safe environment for performing many system recovery and disk management + tasks. Rescue Mode is based on the Finnix recovery distribution, a self-contained and bootable + Linux distribution. You can also use Rescue Mode for tasks other than disaster recovery, + such as formatting disks to use different filesystems, copying data between disks, and + downloading files from a disk via SSH and SFTP. + + Note that “sdh” is reserved and unavailable during rescue. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/rescue + + :param disks: Devices that are either Disks or Volumes + :type: disks: dict + + Example usage: + disks = { + "sda": { + "disk_id": 124458, + "volume_id": null + }, + "sdb": { + "disk_id": null, + "volume_id": null + } + } + """ + if disks: disks = { x: {"disk_id": y} @@ -908,17 +1163,19 @@ def rescue(self, *disks): return result - def kvmify(self): - """ - Converts this linode to KVM from Xen - """ - self._client.post("{}/kvmify".format(Instance.api_endpoint), model=self) - - return True - def mutate(self, allow_auto_disk_resize=True): """ Upgrades this Instance to the latest generation type + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/mutate + + :param allow_auto_disk_resize: Automatically resize disks when resizing a Linode. + When resizing down to a smaller plan your Linode’s + data must fit within the smaller disk size. Defaults to true. + :type: allow_auto_disk_resize: bool + + :returns: True if the operation was successful. + :rtype: bool """ params = {"allow_auto_disk_resize": allow_auto_disk_resize} @@ -933,6 +1190,23 @@ def initiate_migration(self, region=None, upgrade=None): """ Initiates a pending migration that is already scheduled for this Linode Instance + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/migrate + + :param region: The region to which the Linode will be migrated. Must be a valid region slug. + A list of regions can be viewed by using the GET /regions endpoint. A cross data + center migration will cancel a pending migration that has not yet been initiated. + A cross data center migration will initiate a linode_migrate_datacenter_create event. + :type: region: str + + :param upgrade: When initiating a cross DC migration, setting this value to true will also ensure + that the Linode is upgraded to the latest generation of hardware that corresponds to + your Linode’s Type, if any free upgrades are available for it. If no free upgrades + are available, and this value is set to true, then the endpoint will return a 400 + error code and the migration will not be performed. If the data center set in the + region field does not allow upgrades, then the endpoint will return a 400 error + code and the migration will not be performed. + :type: upgrade: bool """ params = { "region": region.id if issubclass(type(region), Base) else region, @@ -948,6 +1222,11 @@ def initiate_migration(self, region=None, upgrade=None): def firewalls(self): """ View Firewall information for Firewalls associated with this Linode. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/firewalls + + :returns: A List of Firewalls of the Linode Instance. + :rtype: List[Firewall] """ from linode_api4.objects import ( # pylint: disable=import-outside-toplevel Firewall, @@ -965,6 +1244,11 @@ def firewalls(self): def nodebalancers(self): """ View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/nodebalancers + + :returns: A List of Nodebalancers of the Linode Instance. + :rtype: List[Nodebalancer] """ from linode_api4.objects import ( # pylint: disable=import-outside-toplevel NodeBalancer, @@ -982,6 +1266,11 @@ def nodebalancers(self): def volumes(self): """ View Block Storage Volumes attached to this Linode. + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/volumes + + :returns: A List of Volumes of the Linode Instance. + :rtype: List[Volume] """ from linode_api4.objects import ( # pylint: disable=import-outside-toplevel Volume, @@ -997,20 +1286,56 @@ def clone( self, to_linode=None, region=None, - service=None, + instance_type=None, configs=[], disks=[], label=None, group=None, with_backups=None, ): - """Clones this linode into a new linode or into a new linode in the given region""" + """ + Clones this linode into a new linode or into a new linode in the given region + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/clone + + :param to_linode: If an existing Linode is the target for the clone, the ID of that + Linode. The existing Linode must have enough resources to accept the clone. + :type: to_linode: int + + :param region: This is the Region where the Linode will be deployed. Region can only be + provided and is required when cloning to a new Linode. + :type: region: str + + :param instance_type: A Linode’s Type determines what resources are available to it, including disk space, + memory, and virtual cpus. The amounts available to a specific Linode are + returned as specs on the Linode object. + :type: instance_type: str + + :param configs: An array of configuration profile IDs. + :type: configs: List of int + + :param disks: An array of disk IDs. + :type: disks: List of int + + :param label: The label to assign this Linode when cloning to a new Linode. + :type: label: str + + :param group: A label used to group Linodes for display. Linodes are not required to have a group. + :type: group: str + + :param with_backups: If this field is set to true, the created Linode will automatically be + enrolled in the Linode Backup service. This will incur an additional charge. + :type: with_backups: bool + + :returns: The cloned Instance. + :rtype: Instance + """ if to_linode and region: raise ValueError( 'You may only specify one of "to_linode" and "region"' ) - if region and not service: + if region and not type: raise ValueError('Specifying a region requires a "service" as well') if not isinstance(configs, list) and not isinstance( @@ -1028,7 +1353,9 @@ def clone( if issubclass(type(to_linode), Base) else to_linode, "region": region.id if issubclass(type(region), Base) else region, - "type": service.id if issubclass(type(service), Base) else service, + "type": instance_type.id + if issubclass(type(instance_type), Base) + else instance_type, "configs": cids if cids else None, "disks": dids if dids else None, "label": label, @@ -1052,6 +1379,11 @@ def clone( def stats(self): """ Returns the JSON stats for this Instance + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/stats + + :returns: The JSON stats for this Instance + :rtype: dict """ # TODO - this would be nicer if we formatted the stats return self._client.get( @@ -1061,6 +1393,14 @@ def stats(self): def stats_for(self, dt): """ Returns stats for the month containing the given datetime + + API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/stats/{year}/{month} + + :param dt: A Datetime for which to return statistics + :type: dt: Datetime + + :returns: The JSON stats for this Instance at the specified Datetime + :rtype: dict """ # TODO - this would be nicer if we formatted the stats if not isinstance(dt, datetime): @@ -1092,6 +1432,13 @@ def __repr__(self): class StackScript(Base): + """ + A script allowing users to reproduce specific software configurations + when deploying Compute Instances, with more user control than static system images. + + View Endpoint: https://api.linode.com/v4/linode/stackscripts/{stackscriptId} + """ + api_endpoint = "/linode/stackscripts/{id}" properties = { "user_defined_fields": Property(), diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index 8f397289a..f0e02e06f 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -206,7 +206,7 @@ def test_resize(self): with self.mock_post(result) as m: linode.resize(new_type="g6-standard-1") self.assertEqual(m.call_url, "/linode/instances/123/resize") - self.assertEqual(m.call_data, {"type": "g6-standard-1"}) + self.assertEqual(m.call_data["type"], "g6-standard-1") def test_resize_with_class(self): """ @@ -219,7 +219,7 @@ def test_resize_with_class(self): with self.mock_post(result) as m: linode.resize(new_type=ltype) self.assertEqual(m.call_url, "/linode/instances/123/resize") - self.assertEqual(m.call_data, {"type": "g6-standard-2"}) + self.assertEqual(m.call_data["type"], "g6-standard-2") def test_boot_with_config(self): """ From 65b718f5fafbba6e51d08cd177f11ed5315eb62f Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Fri, 21 Apr 2023 14:57:30 -0400 Subject: [PATCH 085/379] Brought profile-related functionality to API parity (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Brought profile-related functionality to API parity ## ✔️ How to Test Run `tox`. Ticket: TPT-1893 --- linode_api4/groups/profile.py | 178 ++++++++++++++++++ linode_api4/objects/profile.py | 29 +++ test/fixtures/profile.json | 24 +++ test/fixtures/profile_device_123.json | 8 + test/fixtures/profile_devices.json | 15 ++ test/fixtures/profile_logins.json | 15 ++ test/fixtures/profile_logins_123.json | 8 + test/fixtures/profile_preferences.json | 4 + test/fixtures/profile_security-questions.json | 9 + test/linode_client_test.py | 80 ++++++++ test/objects/profile_test.py | 47 ++++- 11 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/profile.json create mode 100644 test/fixtures/profile_device_123.json create mode 100644 test/fixtures/profile_devices.json create mode 100644 test/fixtures/profile_logins.json create mode 100644 test/fixtures/profile_logins_123.json create mode 100644 test/fixtures/profile_preferences.json create mode 100644 test/fixtures/profile_security-questions.json diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index 8748ec8c7..ef306666e 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -6,9 +6,12 @@ from linode_api4.groups import Group from linode_api4.objects import ( AuthorizedApp, + MappedObject, PersonalAccessToken, Profile, + ProfileLogin, SSHKey, + TrustedDevice, ) @@ -40,6 +43,181 @@ def __call__(self): p = Profile(self.client, result["username"], result) return p + def trusted_devices(self): + """ + Returns the Trusted Devices on your profile. + + API Documentation: https://www.linode.com/docs/api/profile/#trusted-devices-list + + :returns: A list of Trusted Devices for this profile. + :rtype: PaginatedList of TrustedDevice + """ + return self.client._get_and_filter(TrustedDevice) + + def user_preferences(self): + """ + View a list of user preferences tied to the OAuth client that generated the token making the request. + """ + + result = self.client.get( + "{}/preferences".format(Profile.api_endpoint), model=self + ) + + return MappedObject(**result) + + def security_questions(self): + """ + Returns a collection of security questions and their responses, if any, for your User Profile. + """ + + result = self.client.get( + "{}/security-questions".format(Profile.api_endpoint), model=self + ) + + return MappedObject(**result) + + def security_questions_answer(self, questions): + """ + Adds security question responses for your User. Requires exactly three unique questions. + Previous responses are overwritten if answered or reset to null if unanswered. + + Example question: + { + "question_id": 11, + "response": "secret answer 3" + } + """ + + if len(questions) != 3: + raise ValueError("Exactly 3 security questions are required.") + + params = {"security_questions": questions} + + result = self.client.post( + "{}/security-questions".format(Profile.api_endpoint), + model=self, + data=params, + ) + + return MappedObject(**result) + + def user_preferences_update(self, **preferences): + """ + Updates a user’s preferences. + """ + + result = self.client.put( + "{}/preferences".format(Profile.api_endpoint), + model=self, + data=preferences, + ) + + return MappedObject(**result) + + def phone_number_delete(self): + """ + Delete the verified phone number for the User making this request. + + API Documentation: https://api.linode.com/v4/profile/phone-number + + :returns: Returns True if the operation was successful. + :rtype: bool + """ + + resp = self.client.delete( + "{}/phone-number".format(Profile.api_endpoint), model=self + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when deleting phone number!", + json=resp, + ) + + return True + + def phone_number_verify(self, otp_code): + """ + Verify a phone number by confirming the one-time code received via SMS message + after accessing the Phone Verification Code Send (POST /profile/phone-number) command. + + API Documentation: https://api.linode.com/v4/profile/phone-number/verify + + :param otp_code: The one-time code received via SMS message after accessing the Phone Verification Code Send + :type otp_code: str + + :returns: Returns True if the operation was successful. + :rtype: bool + """ + + if not otp_code: + raise ValueError("OTP Code required to verify phone number.") + + params = {"otp_code": str(otp_code)} + + resp = self.client.post( + "{}/phone-number/verify".format(Profile.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when verifying phone number!", + json=resp, + ) + + return True + + def phone_number_verification_code_send(self, iso_code, phone_number): + """ + Send a one-time verification code via SMS message to the submitted phone number. + + API Documentation: https://api.linode.com/v4/profile/phone-number + + :param iso_code: The two-letter ISO 3166 country code associated with the phone number. + :type iso_code: str + + :param phone_number: A valid phone number. + :type phone_number: str + + :returns: Returns True if the operation was successful. + :rtype: bool + """ + + if not iso_code: + raise ValueError("ISO Code required to send verification code.") + + if not phone_number: + raise ValueError("Phone Number required to send verification code.") + + params = {"iso_code": iso_code, "phone_number": phone_number} + + resp = self.client.post( + "{}/phone-number".format(Profile.api_endpoint), + model=self, + data=params, + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when sending verification code!", + json=resp, + ) + + return True + + def logins(self): + """ + Returns the logins on your profile. + + API Documentation: https://www.linode.com/docs/api/profile/#logins-list + + :returns: A list of logins for this profile. + :rtype: PaginatedList of ProfileLogin + """ + return self.client._get_and_filter(ProfileLogin) + def tokens(self, *filters): """ Returns the Person Access Tokens active for this user. diff --git a/linode_api4/objects/profile.py b/linode_api4/objects/profile.py index 85e5baeb3..d72ac9795 100644 --- a/linode_api4/objects/profile.py +++ b/linode_api4/objects/profile.py @@ -56,6 +56,9 @@ class Profile(Base): "authorized_keys": Property(mutable=True), "two_factor_auth": Property(), "restricted": Property(), + "authentication_type": Property(), + "authorized_keys": Property(), + "verified_phone_number": Property(), } def enable_tfa(self): @@ -146,3 +149,29 @@ class SSHKey(Base): "ssh_key": Property(), "created": Property(is_datetime=True), } + + +class TrustedDevice(Base): + api_endpoint = "/profile/devices/{id}" + + properties = { + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + "last_authenticated": Property(is_datetime=True), + "last_remote_addr": Property(), + "user_agent": Property(), + } + + +class ProfileLogin(Base): + api_endpoint = "profile/logins/{id}" + + properties = { + "id": Property(identifier=True), + "datetime": Property(is_datetime=True), + "ip": Property(), + "restricted": Property(), + "status": Property(), + "username": Property(), + } diff --git a/test/fixtures/profile.json b/test/fixtures/profile.json new file mode 100644 index 000000000..2c62a70a0 --- /dev/null +++ b/test/fixtures/profile.json @@ -0,0 +1,24 @@ +{ + "authentication_type": "password", + "authorized_keys": [ + null + ], + "email": "example-user@gmail.com", + "email_notifications": true, + "ip_whitelist_enabled": false, + "lish_auth_method": "keys_only", + "referrals": { + "code": "871be32f49c1411b14f29f618aaf0c14637fb8d3", + "completed": 0, + "credit": 0, + "pending": 0, + "total": 0, + "url": "https://www.linode.com/?r=871be32f49c1411b14f29f618aaf0c14637fb8d3" + }, + "restricted": false, + "timezone": "US/Eastern", + "two_factor_auth": true, + "uid": 1234, + "username": "exampleUser", + "verified_phone_number": "+5555555555" +} \ No newline at end of file diff --git a/test/fixtures/profile_device_123.json b/test/fixtures/profile_device_123.json new file mode 100644 index 000000000..7505373ef --- /dev/null +++ b/test/fixtures/profile_device_123.json @@ -0,0 +1,8 @@ +{ + "created": "2018-01-01T01:01:01", + "expiry": "2018-01-31T01:01:01", + "id": 123, + "last_authenticated": "2018-01-05T12:57:12", + "last_remote_addr": "203.0.113.1", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Vivaldi/2.1.1337.36\n" +} diff --git a/test/fixtures/profile_devices.json b/test/fixtures/profile_devices.json new file mode 100644 index 000000000..0c8ce9322 --- /dev/null +++ b/test/fixtures/profile_devices.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "created": "2018-01-01T01:01:01", + "expiry": "2018-01-31T01:01:01", + "id": 123, + "last_authenticated": "2018-01-05T12:57:12", + "last_remote_addr": "203.0.113.1", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Vivaldi/2.1.1337.36\n" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/profile_logins.json b/test/fixtures/profile_logins.json new file mode 100644 index 000000000..53cc1d8d7 --- /dev/null +++ b/test/fixtures/profile_logins.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "datetime": "2018-01-01T00:01:01", + "id": 123, + "ip": "192.0.2.0", + "restricted": true, + "status": "successful", + "username": "example_user" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/profile_logins_123.json b/test/fixtures/profile_logins_123.json new file mode 100644 index 000000000..0d700f79e --- /dev/null +++ b/test/fixtures/profile_logins_123.json @@ -0,0 +1,8 @@ +{ + "datetime": "2018-01-01T00:01:01", + "id": 123, + "ip": "192.0.2.0", + "restricted": true, + "status": "successful", + "username": "example_user" +} \ No newline at end of file diff --git a/test/fixtures/profile_preferences.json b/test/fixtures/profile_preferences.json new file mode 100644 index 000000000..9b24d09fa --- /dev/null +++ b/test/fixtures/profile_preferences.json @@ -0,0 +1,4 @@ +{ + "key1": "value1", + "key2": "value2" +} \ No newline at end of file diff --git a/test/fixtures/profile_security-questions.json b/test/fixtures/profile_security-questions.json new file mode 100644 index 000000000..7e7821853 --- /dev/null +++ b/test/fixtures/profile_security-questions.json @@ -0,0 +1,9 @@ +{ + "security_questions": [ + { + "id": 1, + "question": "In what city were you born?", + "response": "Gotham City" + } + ] +} \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 7018ede5b..5f40a0946 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -590,6 +590,86 @@ class ProfileGroupTest(ClientBaseCase): Tests methods of the ProfileGroup """ + def test_trusted_devices(self): + devices = self.client.profile.trusted_devices() + self.assertEqual(len(devices), 1) + self.assertEqual(devices[0].id, 123) + + def test_logins(self): + logins = self.client.profile.logins() + self.assertEqual(len(logins), 1) + self.assertEqual(logins[0].id, 123) + + def test_phone_number_delete(self): + with self.mock_delete() as m: + self.client.profile.phone_number_delete() + self.assertEqual(m.call_url, "/profile/phone-number") + + def test_phone_number_verify(self): + with self.mock_post({}) as m: + self.client.profile.phone_number_verify("123456") + self.assertEqual(m.call_url, "/profile/phone-number/verify") + self.assertEqual(m.call_data["otp_code"], "123456") + + def test_phone_number_verification_code_send(self): + with self.mock_post({}) as m: + self.client.profile.phone_number_verification_code_send( + "us", "1234567890" + ) + self.assertEqual(m.call_url, "/profile/phone-number") + self.assertEqual(m.call_data["iso_code"], "us") + self.assertEqual(m.call_data["phone_number"], "1234567890") + + def test_user_preferences(self): + with self.mock_get("/profile/preferences") as m: + result = self.client.profile.user_preferences() + self.assertEqual(m.call_url, "/profile/preferences") + self.assertEqual(result.key1, "value1") + self.assertEqual(result.key2, "value2") + + def test_user_preferences_update(self): + with self.mock_put("/profile/preferences") as m: + self.client.profile.user_preferences_update( + key1="value3", key2="value4" + ) + self.assertEqual(m.call_url, "/profile/preferences") + self.assertEqual(m.call_data["key1"], "value3") + self.assertEqual(m.call_data["key2"], "value4") + + def test_security_questions(self): + with self.mock_get("/profile/security-questions") as m: + result = self.client.profile.security_questions() + self.assertEqual(m.call_url, "/profile/security-questions") + self.assertEqual(result.security_questions[0].id, 1) + self.assertEqual( + result.security_questions[0].question, + "In what city were you born?", + ) + self.assertEqual( + result.security_questions[0].response, "Gotham City" + ) + + def test_security_questions_answer(self): + with self.mock_post("/profile/security-questions") as m: + self.client.profile.security_questions_answer( + [ + {"question_id": 1, "response": "secret answer 1"}, + {"question_id": 2, "response": "secret answer 2"}, + {"question_id": 3, "response": "secret answer 3"}, + ] + ) + self.assertEqual(m.call_url, "/profile/security-questions") + + self.assertEqual( + m.call_data["security_questions"][0]["question_id"], 1 + ) + self.assertEqual( + m.call_data["security_questions"][1]["question_id"], 2 + ) + self.assertEqual( + m.call_data["security_questions"][2]["question_id"], 3 + ) + def test_get_sshkeys(self): """ Tests that a list of SSH Keys can be retrieved diff --git a/test/objects/profile_test.py b/test/objects/profile_test.py index 2c72d9263..58fbdf125 100644 --- a/test/objects/profile_test.py +++ b/test/objects/profile_test.py @@ -1,7 +1,8 @@ from datetime import datetime from test.base import ClientBaseCase -from linode_api4.objects import SSHKey +from linode_api4.objects import Profile, ProfileLogin, SSHKey +from linode_api4.objects.profile import TrustedDevice class SSHKeyTest(ClientBaseCase): @@ -66,3 +67,47 @@ def test_delete_ssh_key(self): key.delete() self.assertEqual(m.call_url, "/profile/sshkeys/22") + + +class ProfileTest(ClientBaseCase): + """ + Tests methods of the Profile class + """ + + def test_get_profile(self): + """ + Tests that a Profile is loaded correctly by ID + """ + profile = Profile(self.client, "exampleUser") + + self.assertEqual(profile.username, "exampleUser") + self.assertEqual(profile.authentication_type, "password") + self.assertIsNotNone(profile.authorized_keys) + self.assertEqual(profile.email, "example-user@gmail.com") + self.assertTrue(profile.email_notifications) + self.assertFalse(profile.ip_whitelist_enabled) + self.assertEqual(profile.lish_auth_method, "keys_only") + self.assertIsNotNone(profile.referrals) + self.assertFalse(profile.restricted) + self.assertEqual(profile.timezone, "US/Eastern") + self.assertTrue(profile.two_factor_auth) + self.assertEqual(profile.uid, 1234) + self.assertEqual(profile.verified_phone_number, "+5555555555") + + def test_get_trusted_device(self): + device = TrustedDevice(self.client, 123) + + self.assertEqual(device.id, 123) + self.assertEqual( + device.user_agent, + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 Vivaldi/2.1.1337.36\n", + ) + + def test_get_login(self): + login = ProfileLogin(self.client, 123) + + self.assertEqual(login.id, 123) + self.assertEqual(login.ip, "192.0.2.0") + self.assertEqual(login.status, "successful") + self.assertEqual(login.username, "example_user") + self.assertTrue(login.restricted) From 72f244077ba85a954145f354ec2445f495921c7c Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Mon, 24 Apr 2023 15:08:15 -0400 Subject: [PATCH 086/379] Fixed wrong documentation links in `objects/linode.py` (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Fixed wrong documentation links in `objects/linode.py` ## ✔️ How to Test `pytest test` --- linode_api4/objects/linode.py | 74 +++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index b23accc80..34847ed85 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -21,7 +21,7 @@ class Backup(DerivedBase): """ A Backup of a Linode Instance. - View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId}/backups/{backupId} + API Documentation: https://www.linode.com/docs/api/linode-instances/#backup-view """ api_endpoint = "/linode/instances/{linode_id}/backups/{id}" @@ -49,7 +49,7 @@ def restore_to(self, linode, **kwargs): """ Restores a Linode’s Backup to the specified Linode. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups/{backupId}/restore + API Documentation: https://www.linode.com/docs/api/linode-instances/#backup-restore :param linode: The id of the Instance or the Instance to share the IPAddresses with. This Instance will be able to bring up the given addresses. @@ -87,7 +87,7 @@ class Disk(DerivedBase): """ A Disk for the storage space on a Compute Instance. - View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId}/disks/{diskId} + API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-view """ api_endpoint = "/linode/instances/{linode_id}/disks/{id}" @@ -129,7 +129,7 @@ def reset_root_password(self, root_password=None): """ Resets the password of a Disk you have permission to read_write. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/disks/{diskId}/password + API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-root-password-reset :param root_password: The new root password for the OS installed on this Disk. The password must meet the complexity strength validation requirements for a strong password. @@ -157,7 +157,7 @@ def resize(self, new_size): fit on the new disk size. You may need to resize the filesystem on the disk first before performing this action. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/disks/{diskId}/resize + API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-resize :param new_size: The intended new size of the disk, in MB :type new_size: int @@ -197,7 +197,7 @@ class Kernel(Base): to compile the kernel from source than to download it from your package manager. For more information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS. - View Endpoint: https://api.linode.com/v4/linode/kernels/{kernelId} + API Documentation: https://www.linode.com/docs/api/linode-instances/#kernel-view """ api_endpoint = "/linode/kernels/{id}" @@ -221,7 +221,7 @@ class Type(Base): """ Linode Plan type to specify the resources available to a Linode Instance. - View Endpoint: https://api.linode.com/v4/linode/types/{typeId} + API Documentation: https://www.linode.com/docs/api/linode-types/#type-view """ api_endpoint = "/linode/types/{id}" @@ -298,7 +298,7 @@ class Config(DerivedBase): """ A Configuration Profile for a Linode Instance. - View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId}/configs/{configId} + API Documentation: https://www.linode.com/docs/api/linode-instances/#configuration-profile-view """ api_endpoint = "/linode/instances/{linode_id}/configs/{id}" @@ -386,7 +386,7 @@ class Instance(Base): """ A Linode Instance. - View Endpoint: https://api.linode.com/v4/linode/instances/{linodeId} + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-view """ api_endpoint = "/linode/instances/{id}" @@ -419,7 +419,7 @@ def ips(self): The ips related collection is not normalized like the others, so we have to make an ad-hoc object to return for its response - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/ips + API Documentation: https://www.linode.com/docs/api/linode-instances/#networking-information-list :returns: A List of the ips of the Linode Instance. :rtype: List[IPAddress] @@ -492,7 +492,7 @@ def available_backups(self): """ The backups response contains what backups are available to be restored. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups + API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-list :returns: A List of the available backups for the Linode Instance. :rtype: List[Backup] @@ -550,7 +550,7 @@ def reset_instance_root_password(self, root_password=None): """ Resets the root password for this Linode. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/password + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-root-password-reset :param root_password: The root user’s password on this Linode. Linode passwords must meet a password strength score requirement that is calculated internally @@ -574,7 +574,7 @@ def transfer_year_month(self, year, month): """ Get per-linode transfer for specified month - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/transfer/{year}/{month} + API Documentation: https://www.linode.com/docs/api/linode-instances/#network-transfer-view-yearmonth :param year: Numeric value representing the year to look up. :type: year: int @@ -598,7 +598,7 @@ def transfer(self): """ Get per-linode transfer - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/transfer + API Documentation: https://www.linode.com/docs/api/linode-instances/#network-transfer-view :returns: The network transfer statistics for the current month. :rtype: MappedObject @@ -652,7 +652,7 @@ def boot(self, config=None): (because the Linode was never booted or the last booted config was deleted) an error will be returned. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/boot + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-boot :param config: The Linode Config ID to boot into. :type: config: int @@ -677,7 +677,7 @@ def shutdown(self): are currently running or queued, those actions must be completed first before you can initiate a shutdown. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/shutdown + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-shut-down :returns: True if the operation was successful. :rtype: bool @@ -696,7 +696,7 @@ def reboot(self): Reboots a Linode you have permission to modify. If any actions are currently running or queued, those actions must be completed first before you can initiate a reboot. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/reboot + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-reboot :returns: True if the operation was successful. :rtype: bool @@ -722,7 +722,7 @@ def resize(self, new_type, allow_auto_disk_resize=True, **kwargs): - The Linode must not have more disk allocation than the new Type allows. - In that situation, you must first delete or resize the disk to be smaller. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/resize + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-resize :param new_type: The Linode Type or the id representing it. :type: new_type: Type or int @@ -785,6 +785,8 @@ def config_create( """ Creates a Linode Config with the given attributes. + API Documentation: https://www.linode.com/docs/api/linode-instances/#configuration-profile-create + :param kernel: The kernel to boot with. :param label: The config label :param disks: The list of disks, starting at sda, to map to this config. @@ -889,6 +891,8 @@ def disk_create( """ Creates a new Disk for this Instance. + API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-create + :param size: The size of the disk, in MB :param label: The label of the disk. If not given, a default label will be generated. :param filesystem: The filesystem type for the disk. If not given, the default @@ -968,11 +972,9 @@ def enable_backups(self): Enable Backups for this Instance. When enabled, we will automatically backup your Instance's data so that it can be restored at a later date. For more information on Instance's Backups service and pricing, see our - `Backups Page`_ - - .. _Backups Page: https://www.linode.com/backups + Backups Page: https://www.linode.com/backups - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups/enable + API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-enable :returns: True if the operation was successful. :rtype: bool @@ -989,7 +991,7 @@ def cancel_backups(self): including any snapshots that have been taken. This cannot be undone, but Backups can be re-enabled at a later date. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups/cancel + API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-cancel :returns: True if the operation was successful. :rtype: bool @@ -1007,7 +1009,7 @@ def snapshot(self, label=None): Important: If you already have a snapshot of this Linode, this is a destructive action. The previous snapshot will be deleted. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/backups + API Documentation: https://www.linode.com/docs/api/linode-instances/#snapshot-create :param label: The label for the new snapshot. :type: label: str @@ -1041,6 +1043,8 @@ def ip_allocate(self, public=False): before you can add one. You may only have, at most, one private IP per Instance. + API Documentation: https://www.linode.com/docs/api/linode-instances/#ipv4-address-allocate + :param public: If the new IP should be public or private. Defaults to private. :type public: bool @@ -1071,6 +1075,8 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): a new :any:`Image` to it. This can be used to reset an existing Instance or to install an Image on an empty Instance. + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-rebuild + :param image: The Image to deploy to this Instance :type image: str or Image :param root_pass: The root password for the newly rebuilt Instance. If @@ -1127,7 +1133,7 @@ def rescue(self, *disks): Note that “sdh” is reserved and unavailable during rescue. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/rescue + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-boot-into-rescue-mode :param disks: Devices that are either Disks or Volumes :type: disks: dict @@ -1167,7 +1173,7 @@ def mutate(self, allow_auto_disk_resize=True): """ Upgrades this Instance to the latest generation type - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/mutate + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-upgrade :param allow_auto_disk_resize: Automatically resize disks when resizing a Linode. When resizing down to a smaller plan your Linode’s @@ -1191,7 +1197,7 @@ def initiate_migration(self, region=None, upgrade=None): Initiates a pending migration that is already scheduled for this Linode Instance - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/migrate + API Documentation: https://www.linode.com/docs/api/linode-instances/#dc-migrationpending-host-migration-initiate :param region: The region to which the Linode will be migrated. Must be a valid region slug. A list of regions can be viewed by using the GET /regions endpoint. A cross data @@ -1223,7 +1229,7 @@ def firewalls(self): """ View Firewall information for Firewalls associated with this Linode. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/firewalls + API Documentation: https://www.linode.com/docs/api/linode-instances/#firewalls-list :returns: A List of Firewalls of the Linode Instance. :rtype: List[Firewall] @@ -1245,7 +1251,7 @@ def nodebalancers(self): """ View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/nodebalancers + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-nodebalancers-view :returns: A List of Nodebalancers of the Linode Instance. :rtype: List[Nodebalancer] @@ -1267,7 +1273,7 @@ def volumes(self): """ View Block Storage Volumes attached to this Linode. - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/volumes + API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-volumes-list :returns: A List of Volumes of the Linode Instance. :rtype: List[Volume] @@ -1296,7 +1302,7 @@ def clone( """ Clones this linode into a new linode or into a new linode in the given region - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/clone + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-clone :param to_linode: If an existing Linode is the target for the clone, the ID of that Linode. The existing Linode must have enough resources to accept the clone. @@ -1380,7 +1386,7 @@ def stats(self): """ Returns the JSON stats for this Instance - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/stats + API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-statistics-view :returns: The JSON stats for this Instance :rtype: dict @@ -1394,7 +1400,7 @@ def stats_for(self, dt): """ Returns stats for the month containing the given datetime - API Documentation: https://api.linode.com/v4/linode/instances/{linodeId}/stats/{year}/{month} + API Documentation: https://www.linode.com/docs/api/linode-instances/#statistics-view-yearmonth :param dt: A Datetime for which to return statistics :type: dt: Datetime @@ -1436,7 +1442,7 @@ class StackScript(Base): A script allowing users to reproduce specific software configurations when deploying Compute Instances, with more user control than static system images. - View Endpoint: https://api.linode.com/v4/linode/stackscripts/{stackscriptId} + API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-view """ api_endpoint = "/linode/stackscripts/{id}" From fceed8da5c1c3a4eed1459f8c1189fc110ebf00a Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:10:46 -0400 Subject: [PATCH 087/379] doc: Add documentation to objects/account.py (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This pull request adds documentation for all models and endpoint methods in `objects/account.py`. --- linode_api4/objects/account.py | 143 +++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index a00679680..793381198 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -20,6 +20,12 @@ class Account(Base): + """ + The contact and billing information related to your Account. + + API Documentation: https://www.linode.com/docs/api/account/#account-view + """ + api_endpoint = "/account" id_attribute = "email" @@ -48,6 +54,12 @@ class Account(Base): class ServiceTransfer(Base): + """ + A transfer request for transferring a service between Linode accounts. + + API Documentation: https://www.linode.com/docs/api/account/#service-transfer-view + """ + api_endpoint = "/account/service-transfers/{token}" id_attribute = "token" properties = { @@ -63,6 +75,8 @@ class ServiceTransfer(Base): def service_transfer_accept(self): """ Accept a Service Transfer for the provided token to receive the services included in the transfer to your account. + + API Documentation: https://www.linode.com/docs/api/account/#service-transfer-accept """ resp = self._client.post( @@ -78,6 +92,12 @@ def service_transfer_accept(self): class PaymentMethod(Base): + """ + A payment method to be used on this Linode account. + + API Documentation: https://www.linode.com/docs/api/account/#payment-method-view + """ + api_endpoint = "/account/payment-methods/{id}" properties = { "id": Property(identifier=True), @@ -90,6 +110,8 @@ class PaymentMethod(Base): def payment_method_make_default(self): """ Make this Payment Method the default method for automatically processing payments. + + API Documentation: https://www.linode.com/docs/api/account/#payment-method-make-default """ resp = self._client.post( @@ -105,6 +127,12 @@ def payment_method_make_default(self): class Login(Base): + """ + A login entry for this account. + + API Documentation: https://www.linode.com/docs/api/account/#login-view + """ + api_endpoint = "/account/logins/{id}" properties = { "id": Property(identifier=True), @@ -117,6 +145,12 @@ class Login(Base): class AccountSettings(Base): + """ + Information related to your Account settings. + + API Documentation: https://www.linode.com/docs/api/account/#account-settings-view + """ + api_endpoint = "/account/settings" id_attribute = "managed" # this isn't actually used @@ -132,6 +166,12 @@ class AccountSettings(Base): class Event(Base): + """ + An event object representing an event that took place on this account. + + API Documentation: https://www.linode.com/docs/api/account/#event-view + """ + api_endpoint = "/account/events/{id}" properties = { "id": Property(identifier=True, filterable=True), @@ -154,45 +194,108 @@ class Event(Base): @property def linode(self): + """ + Returns the Linode Instance referenced by this event. + + :returns: The Linode Instance referenced by this event. + :rtype: Optional[Instance] + """ + if self.entity and self.entity.type == "linode": return Instance(self._client, self.entity.id) return None @property def stackscript(self): + """ + Returns the Linode StackScript referenced by this event. + + :returns: The Linode StackScript referenced by this event. + :rtype: Optional[StackScript] + """ + if self.entity and self.entity.type == "stackscript": return StackScript(self._client, self.entity.id) return None @property def domain(self): + """ + Returns the Linode Domain referenced by this event. + + :returns: The Linode Domain referenced by this event. + :rtype: Optional[Domain] + """ + if self.entity and self.entity.type == "domain": return Domain(self._client, self.entity.id) return None @property def nodebalancer(self): + """ + Returns the Linode NodeBalancer referenced by this event. + + :returns: The Linode NodeBalancer referenced by this event. + :rtype: Optional[NodeBalancer] + """ + if self.entity and self.entity.type == "nodebalancer": return NodeBalancer(self._client, self.entity.id) return None @property def ticket(self): + """ + Returns the Linode Support Ticket referenced by this event. + + :returns: The Linode Support Ticket referenced by this event. + :rtype: Optional[SupportTicket] + """ + if self.entity and self.entity.type == "ticket": return SupportTicket(self._client, self.entity.id) return None @property def volume(self): + """ + Returns the Linode Volume referenced by this event. + + :returns: The Linode Volume referenced by this event. + :rtype: Optional[Volume] + """ + if self.entity and self.entity.type == "volume": return Volume(self._client, self.entity.id) return None def mark_read(self): + """ + Marks a single Event as read. + + API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-read + """ + self._client.post("{}/read".format(Event.api_endpoint), model=self) + def mark_seen(self): + """ + Marks a single Event as seen. + + API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen + """ + + self._client.post("{}/seen".format(Event.api_endpoint), model=self) + class InvoiceItem(DerivedBase): + """ + An individual invoice item under an :any:`Invoice` object. + + API Documentation: https://www.linode.com/docs/api/account/#invoice-items-list + """ + api_endpoint = "/account/invoices/{invoice_id}/items" derived_url_path = "items" parent_id_name = "invoice_id" @@ -222,6 +325,12 @@ def _populate(self, json): class Invoice(Base): + """ + A single invoice on this Linode account. + + API Documentation: https://www.linode.com/docs/api/account/#invoice-view + """ + api_endpoint = "/account/invoices/{id}" properties = { @@ -237,6 +346,12 @@ class Invoice(Base): class OAuthClient(Base): + """ + An OAuthClient object that can be used to authenticate apps with this account. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-client-view + """ + api_endpoint = "/account/oauth-clients/{id}" properties = { @@ -252,6 +367,8 @@ class OAuthClient(Base): def reset_secret(self): """ Resets the client secret for this client. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-client-secret-reset """ result = self._client.post( "{}/reset_secret".format(OAuthClient.api_endpoint), model=self @@ -270,6 +387,8 @@ def thumbnail(self, dump_to=None): This returns binary data that represents a 128x128 image. If dump_to is given, attempts to write the image to a file at the given location. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-client-thumbnail-view """ headers = {"Authorization": "token {}".format(self._client.token)} @@ -296,6 +415,8 @@ def set_thumbnail(self, thumbnail): Sets the thumbnail for this OAuth Client. If thumbnail is bytes, uploads it as a png. Otherwise, assumes thumbnail is a path to the thumbnail and reads it in as bytes before uploading. + + API Documentation: https://www.linode.com/docs/api/account/#oauth-client-thumbnail-update """ headers = { "Authorization": "token {}".format(self._client.token), @@ -327,6 +448,12 @@ def set_thumbnail(self, thumbnail): class Payment(Base): + """ + An object representing a single payment on the current Linode Account. + + API Documentation: https://www.linode.com/docs/api/account/#payment-view + """ + api_endpoint = "/account/payments/{id}" properties = { @@ -337,6 +464,12 @@ class Payment(Base): class User(Base): + """ + An object representing a single user on this account. + + API Documentation: https://www.linode.com/docs/api/account/#user-view + """ + api_endpoint = "/account/users/{id}" id_attribute = "username" @@ -355,6 +488,8 @@ def grants(self): will result in an ApiError. This is smart, and will only fetch from the api once unless the object is invalidated. + API Documentation: https://www.linode.com/docs/api/account/#users-grants-view + :returns: The grants for this user. :rtype: linode.objects.account.UserGrants """ @@ -451,6 +586,8 @@ class UserGrants: This is not an instance of Base because it lacks most of the attributes of a Base-like model (such as a unique, ID-based endpoint at which to access it), however it has some similarities so that its usage is familiar. + + API Documentation: https://www.linode.com/docs/api/account/#users-grants-view """ api_endpoint = "/account/users/{username}/grants" @@ -473,6 +610,12 @@ def _populate(self, json): setattr(self, key, lst) def save(self): + """ + Applies the grants to the parent user. + + API Documentation: https://www.linode.com/docs/api/account/#users-grants-update + """ + req = { "global": { k: v From 72249a77faabe0163c26c94df4df1a2951e8e36a Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Tue, 25 Apr 2023 13:35:28 -0400 Subject: [PATCH 088/379] Added documentation to `objects/tag.py` (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation to `objects/tag.py`. ## ✔️ How to Test 'pytest test' Ticket: TPT-1935 --- linode_api4/objects/tag.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 2ae75223f..5a604d445 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -19,6 +19,13 @@ class Tag(Base): + """ + A User-defined labels attached to objects in your Account, such as Linodes. + Used for specifying and grouping attributes of objects that are relevant to the User. + + API Documentation: https://www.linode.com/docs/api/tags/#tags-list + """ + api_endpoint = "/tags/{label}" id_attribute = "label" @@ -58,6 +65,11 @@ def objects(self): """ Returns a list of objects with this Tag. This list may contain any taggable object type. + + API Documentation: https://www.linode.com/docs/api/tags/#tagged-objects-list + + :returns: Objects with this Tag + :rtype: PaginatedList of objects with this Tag """ data = self._get_raw_objects() @@ -102,6 +114,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): :param json: The JSON to populate the instance with :returns: A new instance of this type, populated with json + :rtype: TaggedObjectProxy """ make_cls = CLASS_MAP.get( id From b466e6e24e085df8913c60c68be730ab950e5cbc Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Tue, 25 Apr 2023 13:39:57 -0400 Subject: [PATCH 089/379] Removed deprecated `filterable` attribute from fields (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Removed deprecated `filterable` attribute from fields since filtering is fully deferred to API. ## ✔️ How to Test `pytest test` Ticket: TPT-1872 --- linode_api4/objects/account.py | 8 ++-- linode_api4/objects/base.py | 3 -- linode_api4/objects/database.py | 42 ++++++++--------- linode_api4/objects/domain.py | 10 ++--- linode_api4/objects/linode.py | 70 ++++++++++++++--------------- linode_api4/objects/longview.py | 2 +- linode_api4/objects/networking.py | 22 ++++----- linode_api4/objects/nodebalancer.py | 2 +- linode_api4/objects/region.py | 2 +- linode_api4/objects/support.py | 2 +- linode_api4/objects/volume.py | 6 +-- 11 files changed, 82 insertions(+), 87 deletions(-) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 793381198..4db05e802 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -174,10 +174,10 @@ class Event(Base): api_endpoint = "/account/events/{id}" properties = { - "id": Property(identifier=True, filterable=True), + "id": Property(identifier=True), "percent_complete": Property(volatile=True), - "created": Property(is_datetime=True, filterable=True), - "updated": Property(is_datetime=True, filterable=True), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), "seen": Property(), "read": Property(), "action": Property(), @@ -356,7 +356,7 @@ class OAuthClient(Base): properties = { "id": Property(identifier=True), - "label": Property(mutable=True, filterable=True), + "label": Property(mutable=True), "secret": Property(), "redirect_uri": Property(mutable=True), "status": Property(), diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index a1cf74977..f3a5bdee6 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -19,7 +19,6 @@ def __init__( relationship=None, derived_class=None, is_datetime=False, - filterable=False, id_relationship=False, slug_relationship=False, ): @@ -35,7 +34,6 @@ def __init__( relationship - The API Object this Property represents derived_class - The sub-collection type this Property represents is_datetime - True if this Property should be parsed as a datetime.datetime - filterable - True if the API allows filtering on this property id_relationship - This Property should create a relationship with this key as the ID (This should be used on fields ending with '_id' only) slug_relationship - This property is a slug related for a given type. @@ -46,7 +44,6 @@ def __init__( self.relationship = relationship self.derived_class = derived_class self.is_datetime = is_datetime - self.filterable = filterable self.id_relationship = id_relationship self.slug_relationship = slug_relationship diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index d32bac7a8..de985c7a1 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -5,7 +5,7 @@ class DatabaseType(Base): api_endpoint = "/databases/types/{id}" properties = { - "deprecated": Property(filterable=True), + "deprecated": Property(), "disk": Property(), "engines": Property(), "id": Property(identifier=True), @@ -32,8 +32,8 @@ class DatabaseEngine(Base): properties = { "id": Property(identifier=True), - "engine": Property(filterable=True), - "version": Property(filterable=True), + "engine": Property(), + "version": Property(), } def invalidate(self): @@ -94,23 +94,23 @@ class MySQLDatabase(Base): properties = { "id": Property(identifier=True), - "label": Property(mutable=True, filterable=True), + "label": Property(mutable=True), "allow_list": Property(mutable=True), "backups": Property(derived_class=MySQLDatabaseBackup), "cluster_size": Property(), "created": Property(is_datetime=True), "encrypted": Property(), - "engine": Property(filterable=True), + "engine": Property(), "hosts": Property(), "port": Property(), - "region": Property(filterable=True), + "region": Property(), "replication_type": Property(), "ssl_connection": Property(), - "status": Property(volatile=True, filterable=True), - "type": Property(filterable=True), + "status": Property(volatile=True), + "type": Property(), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), - "version": Property(filterable=True), + "version": Property(), } @property @@ -193,24 +193,24 @@ class PostgreSQLDatabase(Base): properties = { "id": Property(identifier=True), - "label": Property(mutable=True, filterable=True), + "label": Property(mutable=True), "allow_list": Property(mutable=True), "backups": Property(derived_class=PostgreSQLDatabaseBackup), "cluster_size": Property(), "created": Property(is_datetime=True), "encrypted": Property(), - "engine": Property(filterable=True), + "engine": Property(), "hosts": Property(), "port": Property(), - "region": Property(filterable=True), + "region": Property(), "replication_commit_type": Property(), "replication_type": Property(), "ssl_connection": Property(), - "status": Property(volatile=True, filterable=True), - "type": Property(filterable=True), + "status": Property(volatile=True), + "type": Property(), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), - "version": Property(filterable=True), + "version": Property(), } @property @@ -291,26 +291,26 @@ class MongoDBDatabase(Base): properties = { "id": Property(identifier=True), - "label": Property(mutable=True, filterable=True), + "label": Property(mutable=True), "allow_list": Property(mutable=True), "backups": Property(derived_class=MongoDBDatabaseBackup), "cluster_size": Property(), "compression_type": Property(), "created": Property(is_datetime=True), "encrypted": Property(), - "engine": Property(filterable=True), + "engine": Property(), "hosts": Property(), "peers": Property(), "port": Property(), - "region": Property(filterable=True), + "region": Property(), "replica_set": Property(), "ssl_connection": Property(), - "status": Property(volatile=True, filterable=True), + "status": Property(volatile=True), "storage_engine": Property(), - "type": Property(filterable=True), + "type": Property(), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), - "version": Property(filterable=True), + "version": Property(), } @property diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index 8c151a2a4..92be03974 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -11,8 +11,8 @@ class DomainRecord(DerivedBase): "id": Property(identifier=True), "domain_id": Property(identifier=True), "type": Property(), - "name": Property(mutable=True, filterable=True), - "target": Property(mutable=True, filterable=True), + "name": Property(mutable=True), + "target": Property(mutable=True), "priority": Property(mutable=True), "weight": Property(mutable=True), "port": Property(mutable=True), @@ -29,13 +29,13 @@ class Domain(Base): api_endpoint = "/domains/{id}" properties = { "id": Property(identifier=True), - "domain": Property(mutable=True, filterable=True), - "group": Property(mutable=True, filterable=True), + "domain": Property(mutable=True), + "group": Property(mutable=True), "description": Property(mutable=True), "status": Property(mutable=True), "soa_email": Property(mutable=True), "retry_sec": Property(mutable=True), - "master_ips": Property(mutable=True, filterable=True), + "master_ips": Property(mutable=True), "axfr_ips": Property(mutable=True), "expire_sec": Property(mutable=True), "refresh_sec": Property(mutable=True), diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 34847ed85..cb2a533f6 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -97,9 +97,9 @@ class Disk(DerivedBase): properties = { "id": Property(identifier=True), "created": Property(is_datetime=True), - "label": Property(mutable=True, filterable=True), - "size": Property(filterable=True), - "status": Property(filterable=True, volatile=True), + "label": Property(mutable=True), + "size": Property(), + "status": Property(volatile=True), "filesystem": Property(), "updated": Property(is_datetime=True), "linode_id": Property(identifier=True), @@ -203,17 +203,17 @@ class Kernel(Base): api_endpoint = "/linode/kernels/{id}" properties = { "created": Property(is_datetime=True), - "deprecated": Property(filterable=True), + "deprecated": Property(), "description": Property(), "id": Property(identifier=True), - "kvm": Property(filterable=True), - "label": Property(filterable=True), + "kvm": Property(), + "label": Property(), "updates": Property(), - "version": Property(filterable=True), - "architecture": Property(filterable=True), - "xen": Property(filterable=True), + "version": Property(), + "architecture": Property(), + "xen": Property(), "built": Property(), - "pvops": Property(filterable=True), + "pvops": Property(), } @@ -226,16 +226,16 @@ class Type(Base): api_endpoint = "/linode/types/{id}" properties = { - "disk": Property(filterable=True), + "disk": Property(), "id": Property(identifier=True), - "label": Property(filterable=True), - "network_out": Property(filterable=True), + "label": Property(), + "network_out": Property(), "price": Property(), "addons": Property(), - "memory": Property(filterable=True), - "transfer": Property(filterable=True), - "vcpus": Property(filterable=True), - "gpus": Property(filterable=True), + "memory": Property(), + "transfer": Property(), + "vcpus": Property(), + "gpus": Property(), "successor": Property(), # type_class is populated from the 'class' attribute of the returned JSON } @@ -311,15 +311,15 @@ class Config(DerivedBase): "helpers": Property(), # TODO: mutable=True), "created": Property(is_datetime=True), "root_device": Property(mutable=True), - "kernel": Property(relationship=Kernel, mutable=True, filterable=True), - "devices": Property(filterable=True), # TODO: mutable=True), + "kernel": Property(relationship=Kernel, mutable=True), + "devices": Property(), # TODO: mutable=True), "initrd": Property(relationship=Disk), "updated": Property(), - "comments": Property(mutable=True, filterable=True), - "label": Property(mutable=True, filterable=True), - "run_level": Property(mutable=True, filterable=True), - "virt_mode": Property(mutable=True, filterable=True), - "memory_limit": Property(mutable=True, filterable=True), + "comments": Property(mutable=True), + "label": Property(mutable=True), + "run_level": Property(mutable=True), + "virt_mode": Property(mutable=True), + "memory_limit": Property(mutable=True), "interfaces": Property(mutable=True), # gets setup in _populate below "helpers": Property(mutable=True), } @@ -391,15 +391,15 @@ class Instance(Base): api_endpoint = "/linode/instances/{id}" properties = { - "id": Property(identifier=True, filterable=True), - "label": Property(mutable=True, filterable=True), - "group": Property(mutable=True, filterable=True), + "id": Property(identifier=True), + "label": Property(mutable=True), + "group": Property(mutable=True), "status": Property(volatile=True), "created": Property(is_datetime=True), "updated": Property(volatile=True, is_datetime=True), - "region": Property(slug_relationship=Region, filterable=True), + "region": Property(slug_relationship=Region), "alerts": Property(mutable=True), - "image": Property(slug_relationship=Image, filterable=True), + "image": Property(slug_relationship=Image), "disks": Property(derived_class=Disk), "configs": Property(derived_class=Config), "type": Property(slug_relationship=Type), @@ -1448,19 +1448,17 @@ class StackScript(Base): api_endpoint = "/linode/stackscripts/{id}" properties = { "user_defined_fields": Property(), - "label": Property(mutable=True, filterable=True), + "label": Property(mutable=True), "rev_note": Property(mutable=True), - "username": Property(filterable=True), + "username": Property(), "user_gravatar_id": Property(), - "is_public": Property(mutable=True, filterable=True), + "is_public": Property(mutable=True), "created": Property(is_datetime=True), "deployments_active": Property(), "script": Property(mutable=True), - "images": Property( - mutable=True, filterable=True - ), # TODO make slug_relationship + "images": Property(mutable=True), # TODO make slug_relationship "deployments_total": Property(), - "description": Property(mutable=True, filterable=True), + "description": Property(mutable=True), "updated": Property(is_datetime=True), } diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py index cfbb3984d..f59d4468a 100644 --- a/linode_api4/objects/longview.py +++ b/linode_api4/objects/longview.py @@ -8,7 +8,7 @@ class LongviewClient(Base): "id": Property(identifier=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), - "label": Property(mutable=True, filterable=True), + "label": Property(mutable=True), "install_code": Property(), "apps": Property(), "api_key": Property(), diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index a1c2d149f..abca783c8 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -12,7 +12,7 @@ class IPv6Pool(Base): properties = { "range": Property(identifier=True), - "region": Property(slug_relationship=Region, filterable=True), + "region": Property(slug_relationship=Region), } @@ -22,7 +22,7 @@ class IPv6Range(Base): properties = { "range": Property(identifier=True), - "region": Property(slug_relationship=Region, filterable=True), + "region": Property(slug_relationship=Region), "prefix": Property(), "route_target": Property(), } @@ -45,7 +45,7 @@ class IPAddress(Base): "public": Property(), "rdns": Property(mutable=True), "linode_id": Property(), - "region": Property(slug_relationship=Region, filterable=True), + "region": Property(slug_relationship=Region), } @property @@ -82,8 +82,8 @@ class VLAN(Base): properties = { "label": Property(identifier=True), "created": Property(is_datetime=True), - "linodes": Property(filterable=True), - "region": Property(slug_relationship=Region, filterable=True), + "linodes": Property(), + "region": Property(slug_relationship=Region), } @@ -93,8 +93,8 @@ class FirewallDevice(DerivedBase): parent_id_name = "firewall_id" properties = { - "created": Property(filterable=True, is_datetime=True), - "updated": Property(filterable=True, is_datetime=True), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), "entity": Property(), "id": Property(identifier=True), } @@ -109,11 +109,11 @@ class Firewall(Base): properties = { "id": Property(identifier=True), - "label": Property(mutable=True, filterable=True), - "tags": Property(mutable=True, filterable=True), + "label": Property(mutable=True), + "tags": Property(mutable=True), "status": Property(mutable=True), - "created": Property(filterable=True, is_datetime=True), - "updated": Property(filterable=True, is_datetime=True), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), "devices": Property(derived_class=FirewallDevice), "rules": Property(), } diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index f228469c8..712586695 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -163,7 +163,7 @@ class NodeBalancer(Base): "updated": Property(is_datetime=True), "ipv4": Property(relationship=IPAddress), "ipv6": Property(), - "region": Property(slug_relationship=Region, filterable=True), + "region": Property(slug_relationship=Region), "configs": Property(derived_class=NodeBalancerConfig), } diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 8e127a760..6a56c37dd 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -5,7 +5,7 @@ class Region(Base): api_endpoint = "/regions/{id}" properties = { "id": Property(identifier=True), - "country": Property(filterable=True), + "country": Property(), "capabilities": Property(), "status": Property(), "resolvers": Property(), diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index c12def1a1..42f57e60e 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -33,7 +33,7 @@ class SupportTicket(Base): "id": Property(identifier=True), "summary": Property(), "description": Property(), - "status": Property(filterable=True), + "status": Property(), "entity": Property(), "opened": Property(is_datetime=True), "closed": Property(is_datetime=True), diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 5861613f0..1bd68110c 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -10,9 +10,9 @@ class Volume(Base): "created": Property(is_datetime=True), "updated": Property(is_datetime=True), "linode_id": Property(id_relationship=Instance), - "label": Property(mutable=True, filterable=True), - "size": Property(filterable=True), - "status": Property(filterable=True), + "label": Property(mutable=True), + "size": Property(), + "status": Property(), "region": Property(slug_relationship=Region), "tags": Property(mutable=True), "filesystem_path": Property(), From 98ebfd2b0f7cadc091131628f76ac73c017e2535 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 25 Apr 2023 16:26:32 -0400 Subject: [PATCH 090/379] new: Add `ExplicitNullValue` class (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change introduces an `ExplicitNullValue` class that can be used to specify null values that should explicitly be included in API put requests. For example, you can reset the RDNS for an IP address by explicitly specifying `rdns` as null when making a request to this endpoint: https://www.linode.com/docs/api/networking/#ip-address-rdns-update__request-body-schema The corresponding implementation would look like this: ```python ip = IPAddress(client, "127.0.0.1") ip.rdns = ExplicitNullValue ip.save() ``` ## ✔️ How to Test ``` tox ``` --- linode_api4/objects/__init__.py | 2 +- linode_api4/objects/base.py | 39 +++++++++++++++++---- linode_api4/objects/networking.py | 13 +++++++ test/fixtures/networking_ips_127.0.0.1.json | 11 ++++++ test/objects/networking_test.py | 27 ++++++++++++++ 5 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/networking_ips_127.0.0.1.json diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 84c2301a8..33c2800e8 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -1,5 +1,5 @@ # isort: skip_file -from .base import Base, Property, MappedObject, DATE_FORMAT +from .base import Base, Property, MappedObject, DATE_FORMAT, ExplicitNullValue from .dbase import DerivedBase from .filtering import and_, or_ from .region import Region diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index f3a5bdee6..26859087c 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -10,6 +10,14 @@ volatile_refresh_timeout = timedelta(seconds=15) +class ExplicitNullValue: + """ + An explicitly null value to set a property to. + Instances of `NullValue` differ from None as they will be explicitly + included in the resource PUT requests. + """ + + class Property: def __init__( self, @@ -21,6 +29,7 @@ def __init__( is_datetime=False, id_relationship=False, slug_relationship=False, + nullable=False, ): """ A Property is an attribute returned from the API, and defines metadata @@ -37,6 +46,7 @@ def __init__( id_relationship - This Property should create a relationship with this key as the ID (This should be used on fields ending with '_id' only) slug_relationship - This property is a slug related for a given type. + nullable - This property can be explicitly null on PUT requests. """ self.mutable = mutable self.identifier = identifier @@ -241,19 +251,36 @@ def _serialize(self): A helper method to build a dict of all mutable Properties of this object """ - result = { - a: getattr(self, a) - for a in type(self).properties - if type(self).properties[a].mutable - } + result = {} + + # Aggregate mutable values into a dict + for k, v in type(self).properties.items(): + if not v.mutable: + continue + + value = getattr(self, k) + + if not value: + continue + + # Let's allow explicit null values as both classes and instances + if ( + isinstance(value, ExplicitNullValue) + or value == ExplicitNullValue + ): + value = None + + result[k] = value + + # Resolve the underlying IDs of results for k, v in result.items(): if isinstance(v, Base): result[k] = v.id elif isinstance(v, MappedObject): result[k] = v.dict - return {k: v for k, v in result.items() if v} + return result def _api_get(self): """ diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index abca783c8..8ebd3a937 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -31,6 +31,19 @@ class IPv6Range(Base): class IPAddress(Base): """ note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + Represents a Linode IP address object. + + When attempting to reset the `rdns` field to default, consider using the ExplicitNullValue class:: + + ip = IPAddress(client, "127.0.0.1") + ip.rdns = ExplicitNullValue + ip.save() + + # Re-populate all attributes with new information from the API + ip.invalidate() + + API Documentation: https://www.linode.com/docs/api/networking/#ip-address-view """ api_endpoint = "/networking/ips/{address}" diff --git a/test/fixtures/networking_ips_127.0.0.1.json b/test/fixtures/networking_ips_127.0.0.1.json new file mode 100644 index 000000000..f6567ebd5 --- /dev/null +++ b/test/fixtures/networking_ips_127.0.0.1.json @@ -0,0 +1,11 @@ +{ + "address": "127.0.0.1", + "gateway": "127.0.0.1", + "linode_id": 123, + "prefix": 24, + "public": true, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4" +} \ No newline at end of file diff --git a/test/objects/networking_test.py b/test/objects/networking_test.py index de04ad65b..51db14e48 100644 --- a/test/objects/networking_test.py +++ b/test/objects/networking_test.py @@ -1,5 +1,6 @@ from test.base import ClientBaseCase +from linode_api4 import ExplicitNullValue from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range @@ -43,3 +44,29 @@ def test_get_rules(self): self.assertEqual(result["outbound"], []) self.assertEqual(result["inbound_policy"], "DROP") self.assertEqual(result["outbound_policy"], "DROP") + + def test_rdns_reset(self): + """ + Tests that the RDNS of an IP and be reset using an explicit null value. + """ + + ip = IPAddress(self.client, "127.0.0.1") + + with self.mock_put("/networking/ips/127.0.0.1") as m: + ip.rdns = ExplicitNullValue() + ip.save() + + self.assertEqual(m.call_url, "/networking/ips/127.0.0.1") + + # We need to assert of call_data_raw because + # call_data drops keys with null values + self.assertEqual(m.call_data_raw, '{"rdns": null}') + + # Ensure that everything works as expected with a class reference + with self.mock_put("/networking/ips/127.0.0.1") as m: + ip.rdns = ExplicitNullValue + ip.save() + + self.assertEqual(m.call_url, "/networking/ips/127.0.0.1") + + self.assertEqual(m.call_data_raw, '{"rdns": null}') From 8b56a589d32e55b8853b40ffb43b60f42a92900a Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 11:41:00 -0400 Subject: [PATCH 091/379] Added documentation for `objects/database.py`. (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation for `objects/database.py`. ## ✔️ How to Test `tox` Ticket: TPT-1923 --- linode_api4/objects/database.py | 159 +++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index de985c7a1..4b50fa12c 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -2,6 +2,12 @@ class DatabaseType(Base): + """ + The type of a managed database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-database-type-view + """ + api_endpoint = "/databases/types/{id}" properties = { @@ -28,6 +34,16 @@ def _populate(self, json): class DatabaseEngine(Base): + """ + A managed database engine. The following database engines are available on Linode’s platform: + + - MySQL + - PostgreSQL + - MongoDB + + API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engine-view + """ + api_endpoint = "/databases/engines/{id}" properties = { @@ -70,6 +86,12 @@ class DatabaseBackup(DerivedBase): def restore(self): """ Restore a backup to a Managed Database on your Account. + + API Documentation: + + - MongoDB: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-restore + - MySQL: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-restore + - PostgreSQL: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-restore """ return self._client.post( @@ -78,18 +100,42 @@ def restore(self): class MySQLDatabaseBackup(DatabaseBackup): + """ + A backup for an accessible Managed MySQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-view + """ + api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" class MongoDBDatabaseBackup(DatabaseBackup): + """ + A backup for an accessible Managed MongoDB Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-view + """ + api_endpoint = "/databases/mongodb/instances/{database_id}/backups/{id}" class PostgreSQLDatabaseBackup(DatabaseBackup): + """ + A backup for an accessible Managed PostgreSQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-view + """ + api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" class MySQLDatabase(Base): + """ + An accessible Managed MySQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-view + """ + api_endpoint = "/databases/mysql/instances/{id}" properties = { @@ -115,6 +161,16 @@ class MySQLDatabase(Base): @property def credentials(self): + """ + Display the root username and password for an accessible Managed MySQL Database. + The Database must have an active status to perform this command. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-credentials-view + + :returns: MappedObject containing credntials for this DB + :rtype: MappedObject + """ + if not hasattr(self, "_credentials"): resp = self._client.get( "{}/credentials".format(MySQLDatabase.api_endpoint), model=self @@ -125,6 +181,15 @@ def credentials(self): @property def ssl(self): + """ + Display the SSL CA certificate for an accessible Managed MySQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-ssl-certificate-view + + :returns: MappedObject containing SSL CA certificate for this DB + :rtype: MappedObject + """ + if not hasattr(self, "_ssl"): resp = self._client.get( "{}/ssl".format(MySQLDatabase.api_endpoint), model=self @@ -136,6 +201,11 @@ def ssl(self): def credentials_reset(self): """ Reset the root password for a Managed MySQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-credentials-reset + + :returns: Response from the API call to reset credentials + :rtype: dict """ self.invalidate() @@ -148,6 +218,11 @@ def credentials_reset(self): def patch(self): """ Apply security patches and updates to the underlying operating system of the Managed MySQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-patch + + :returns: Response from the API call to apply security patches + :rtype: dict """ self.invalidate() @@ -160,8 +235,7 @@ def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed MySQL Database. - :param label: The name for this backup - :type label: str + API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-snapshot-create """ params = { @@ -189,6 +263,12 @@ def invalidate(self): class PostgreSQLDatabase(Base): + """ + An accessible Managed PostgreSQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-view + """ + api_endpoint = "/databases/postgresql/instances/{id}" properties = { @@ -215,6 +295,16 @@ class PostgreSQLDatabase(Base): @property def credentials(self): + """ + Display the root username and password for an accessible Managed PostgreSQL Database. + The Database must have an active status to perform this command. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-credentials-view + + :returns: MappedObject containing credntials for this DB + :rtype: MappedObject + """ + if not hasattr(self, "_credentials"): resp = self._client.get( "{}/credentials".format(PostgreSQLDatabase.api_endpoint), @@ -226,6 +316,15 @@ def credentials(self): @property def ssl(self): + """ + Display the SSL CA certificate for an accessible Managed PostgreSQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-ssl-certificate-view + + :returns: MappedObject containing SSL CA certificate for this DB + :rtype: MappedObject + """ + if not hasattr(self, "_ssl"): resp = self._client.get( "{}/ssl".format(PostgreSQLDatabase.api_endpoint), model=self @@ -237,6 +336,11 @@ def ssl(self): def credentials_reset(self): """ Reset the root password for a Managed PostgreSQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-credentials-reset + + :returns: Response from the API call to reset credentials + :rtype: dict """ self.invalidate() @@ -249,6 +353,11 @@ def credentials_reset(self): def patch(self): """ Apply security patches and updates to the underlying operating system of the Managed PostgreSQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-patch + + :returns: Response from the API call to apply security patches + :rtype: dict """ self.invalidate() @@ -260,6 +369,8 @@ def patch(self): def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed PostgreSQL Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-snapshot-create """ params = { @@ -287,6 +398,12 @@ def invalidate(self): class MongoDBDatabase(Base): + """ + An accessible Managed MongoDB Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-view + """ + api_endpoint = "/databases/mongodb/instances/{id}" properties = { @@ -315,6 +432,16 @@ class MongoDBDatabase(Base): @property def credentials(self): + """ + Display the root username and password for an accessible Managed MongoDB Database. + The Database must have an active status to perform this command. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-credentials-view + + :returns: MappedObject containing credntials for this DB + :rtype: MappedObject + """ + if not hasattr(self, "_credentials"): resp = self._client.get( "{}/credentials".format(MongoDBDatabase.api_endpoint), @@ -326,6 +453,15 @@ def credentials(self): @property def ssl(self): + """ + Display the SSL CA certificate for an accessible Managed MongoDB Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-ssl-certificate-view + + :returns: MappedObject containing SSL CA certificate for this DB + :rtype: MappedObject + """ + if not hasattr(self, "_ssl"): resp = self._client.get( "{}/ssl".format(MongoDBDatabase.api_endpoint), model=self @@ -337,6 +473,11 @@ def ssl(self): def credentials_reset(self): """ Reset the root password for a Managed MongoDB Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-credentials-reset + + :returns: Response from the API call to reset credentials + :rtype: dict """ self.invalidate() @@ -349,6 +490,11 @@ def credentials_reset(self): def patch(self): """ Apply security patches and updates to the underlying operating system of the Managed MongoDB Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-patch + + :returns: Response from the API call to apply security patches + :rtype: dict """ self.invalidate() @@ -360,6 +506,8 @@ def patch(self): def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed MongoDB Database. + + API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-snapshot-create """ params = { @@ -396,6 +544,9 @@ def invalidate(self): class Database(Base): """ A generic Database instance. + + Note: This class does not have a corresponding GET endpoint. For detailed information + about the database, use the .instance() property method instead. """ api_endpoint = "/databases/instances/{id}" @@ -444,3 +595,7 @@ def instance(self): ) return self._instance + + # Since this class doesn't have a corresponding GET endpoint, this prevents an accidental call to the nonexisting endpoint. + def _api_get(self): + return From 0d58bc06777160f990a5a84da04bbea424a18aa7 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 13:12:52 -0400 Subject: [PATCH 092/379] Added documentation for `image.py`, `lke,py`, and `longview.py`. (#265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation for `objects/image.py`, `objects/lke,py`, and `objects/longview.py`. ## ✔️ How to Test `pytest test` Tickets: TPT-1925, TPT-1927, TPT-1928 Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- linode_api4/objects/image.py | 2 ++ linode_api4/objects/lke.py | 53 ++++++++++++++++++++++++++++++++- linode_api4/objects/longview.py | 12 ++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index b7d763d00..381bb00e6 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -4,6 +4,8 @@ class Image(Base): """ An Image is something a Linode Instance or Disk can be deployed from. + + API Documentation: https://www.linode.com/docs/api/images/#image-view """ api_endpoint = "/images/{id}" diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 1396dbda8..a1ca206e5 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -13,6 +13,8 @@ class KubeVersion(Base): """ A KubeVersion is a version of Kubernetes that can be deployed on LKE. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-version-view """ api_endpoint = "/lke/versions/{id}" @@ -52,6 +54,8 @@ class LKENodePool(DerivedBase): """ An LKE Node Pool describes a pool of Linode Instances that exist within an LKE Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-view """ api_endpoint = "/lke/clusters/{cluster_id}/pools/{id}" @@ -88,6 +92,8 @@ def recycle(self): Deleted and recreates all Linodes in this Node Pool in a rolling fashion. Completing this operation may take several minutes. This operation will cause all local data on Linode Instances in this pool to be lost. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-recycle """ self._client.post( "{}/recycle".format(LKENodePool.api_endpoint), model=self @@ -98,6 +104,8 @@ def recycle(self): class LKECluster(Base): """ An LKE Cluster is a single k8s cluster deployed via Linode Kubernetes Engine. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-view """ api_endpoint = "/lke/clusters/{id}" @@ -118,6 +126,11 @@ class LKECluster(Base): def api_endpoints(self): """ A list of API Endpoints for this Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-api-endpoints-list + + :returns: A list of MappedObjects of the API Endpoints + :rtype: List[MappedObject] """ # This result appears to be a PaginatedList, but objects in the list don't # have IDs and can't be retrieved on their own, and it doesn't accept normal @@ -151,6 +164,11 @@ def kubeconfig(self): It may take a few minutes for a config to be ready when creating a new cluster; during that time this request may fail. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubeconfig-view + + :returns: The Kubeconfig file for this Cluster. + :rtype: str """ if not hasattr(self, "_kubeconfig"): result = self._client.get( @@ -165,6 +183,8 @@ def node_pool_create(self, node_type, node_count, **kwargs): """ Creates a new :any:`LKENodePool` for this cluster. + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-create + :param node_type: The type of nodes to create in this pool. :type node_type: :any:`Type` or str :param node_count: The number of nodes to create in this pool. @@ -196,6 +216,11 @@ def node_pool_create(self, node_type, node_count, **kwargs): def cluster_dashboard_url_view(self): """ Get a Kubernetes Dashboard access URL for this Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-dashboard-url-view + + :returns: The Kubernetes Dashboard access URL for this Cluster. + :rtype: str """ result = self._client.get( @@ -207,6 +232,8 @@ def cluster_dashboard_url_view(self): def kubeconfig_delete(self): """ Delete and regenerate the Kubeconfig file for a Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubeconfig-delete """ self._client.delete( @@ -215,7 +242,15 @@ def kubeconfig_delete(self): def node_view(self, nodeId): """ - Get a specific Node Pool by ID. + Get a specific Node by ID. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-view + + :param nodeId: ID of the Node to look up. + :type nodeId: str + + :returns: The specified Node + :rtype: LKENodePoolNode """ node = self._client.get( @@ -227,6 +262,11 @@ def node_view(self, nodeId): def node_delete(self, nodeId): """ Delete a specific Node from a Node Pool. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-delete + + :param nodeId: ID of the Node to delete. + :type nodeId: str """ self._client.delete( @@ -236,6 +276,11 @@ def node_delete(self, nodeId): def node_recycle(self, nodeId): """ Recycle a specific Node from an LKE cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-recycle + + :param nodeId: ID of the Node to recycle. + :type nodeId: str """ self._client.post( @@ -246,6 +291,8 @@ def node_recycle(self, nodeId): def cluster_nodes_recycle(self): """ Recycles all nodes in all pools of a designated Kubernetes Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#cluster-nodes-recycle """ self._client.post( @@ -255,6 +302,8 @@ def cluster_nodes_recycle(self): def cluster_regenerate(self): """ Regenerate the Kubeconfig file and/or the service account token for a Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-regenerate """ self._client.post( @@ -264,6 +313,8 @@ def cluster_regenerate(self): def service_token_delete(self): """ Delete and regenerate the service account token for a Cluster. + + API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#service-token-delete """ self._client.delete( diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py index f59d4468a..71bfa389c 100644 --- a/linode_api4/objects/longview.py +++ b/linode_api4/objects/longview.py @@ -2,6 +2,12 @@ class LongviewClient(Base): + """ + A Longview Client that is accessible for use. Longview is Linode’s system data graphing service. + + API Documentation: https://www.linode.com/docs/api/longview/#longview-client-view + """ + api_endpoint = "/longview/clients/{id}" properties = { @@ -16,6 +22,12 @@ class LongviewClient(Base): class LongviewSubscription(Base): + """ + Contains the Longview Plan details for a specific subscription id. + + API Documentation: https://www.linode.com/docs/api/longview/#longview-subscription-view + """ + api_endpoint = "/longview/subscriptions/{id}" properties = { From 5c09cde2ac32430405a97efa004666e07a873df2 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 13:13:47 -0400 Subject: [PATCH 093/379] Added documentation for `region.py` and `profile.py` (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation for `objects/region.py` and `objects/profile.py` ## ✔️ How to Test `pytest test` Tickets: TPT-1932 and TPT-1933 --- linode_api4/objects/profile.py | 64 ++++++++++++++++++++++++++++++++++ linode_api4/objects/region.py | 6 ++++ 2 files changed, 70 insertions(+) diff --git a/linode_api4/objects/profile.py b/linode_api4/objects/profile.py index d72ac9795..1b9be8305 100644 --- a/linode_api4/objects/profile.py +++ b/linode_api4/objects/profile.py @@ -3,6 +3,12 @@ class AuthorizedApp(Base): + """ + An application with authorized access to an account. + + API Documentation: https://www.linode.com/docs/api/profile/#authorized-app-view + """ + api_endpoint = "/profile/apps/{id}" properties = { @@ -17,6 +23,12 @@ class AuthorizedApp(Base): class PersonalAccessToken(Base): + """ + A Person Access Token associated with a Profile. + + API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-view + """ + api_endpoint = "/profile/tokens/{id}" properties = { @@ -30,6 +42,10 @@ class PersonalAccessToken(Base): class WhitelistEntry(Base): + """ + DEPRECATED: Limited to customers with a feature tag + """ + api_endpoint = "/profile/whitelist/{id}" properties = { @@ -41,6 +57,12 @@ class WhitelistEntry(Base): class Profile(Base): + """ + A Profile containing information about the current User. + + API Documentation: https://www.linode.com/docs/api/profile/#profile-view + """ + api_endpoint = "/profile" id_attribute = "username" @@ -65,6 +87,11 @@ def enable_tfa(self): """ Enables TFA for the token's user. This requies a follow-up request to confirm TFA. Returns the TFA secret that needs to be confirmed. + + API Documentation: https://www.linode.com/docs/api/profile/#two-factor-secret-create + + :returns: The TFA secret + :rtype: str """ result = self._client.post("/profile/tfa-enable") @@ -73,6 +100,15 @@ def enable_tfa(self): def confirm_tfa(self, code): """ Confirms TFA for an account. Needs a TFA code generated by enable_tfa + + API Documentation: https://www.linode.com/docs/api/profile/#two-factor-authentication-confirmenable + + :param code: The Two Factor code you generated with your Two Factor secret. + These codes are time-based, so be sure it is current. + :type code: str + + :returns: Returns true if operation was successful + :rtype: bool """ self._client.post( "/profile/tfa-enable-confirm", data={"tfa_code": code} @@ -83,6 +119,11 @@ def confirm_tfa(self, code): def disable_tfa(self): """ Turns off TFA for this user's account. + + API Documentation: https://www.linode.com/docs/api/profile/#two-factor-authentication-disable + + :returns: Returns true if operation was successful + :rtype: bool """ self._client.post("/profile/tfa-disable") @@ -92,6 +133,11 @@ def disable_tfa(self): def grants(self): """ Returns grants for the current user + + API Documentation: https://www.linode.com/docs/api/profile/#grants-list + + :returns: The grants for the current user + :rtype: UserGrants """ from linode_api4.objects.account import ( # pylint: disable-all UserGrants, @@ -112,12 +158,16 @@ def grants(self): def whitelist(self): """ Returns the user's whitelist entries, if whitelist is enabled + + DEPRECATED: Limited to customers with a feature tag """ return self._client._get_and_filter(WhitelistEntry) def add_whitelist_entry(self, address, netmask, note=None): """ Adds a new entry to this user's IP whitelist, if enabled + + DEPRECATED: Limited to customers with a feature tag """ result = self._client.post( "{}/whitelist".format(Profile.api_endpoint), @@ -139,6 +189,8 @@ def add_whitelist_entry(self, address, netmask, note=None): class SSHKey(Base): """ An SSH Public Key uploaded to your profile for use in Linode Instance deployments. + + API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-view """ api_endpoint = "/profile/sshkeys/{id}" @@ -152,6 +204,12 @@ class SSHKey(Base): class TrustedDevice(Base): + """ + A Trusted Device for a User. + + API Documentation: https://www.linode.com/docs/api/profile/#trusted-device-view + """ + api_endpoint = "/profile/devices/{id}" properties = { @@ -165,6 +223,12 @@ class TrustedDevice(Base): class ProfileLogin(Base): + """ + A Login object displaying information about a successful account login from this user. + + API Documentation: https://www.linode.com/docs/api/profile/#login-view + """ + api_endpoint = "profile/logins/{id}" properties = { diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 6a56c37dd..a9919f94b 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -2,6 +2,12 @@ class Region(Base): + """ + A Region. Regions correspond to individual data centers, each located in a different geographical area. + + API Documentation: https://www.linode.com/docs/api/regions/#region-view + """ + api_endpoint = "/regions/{id}" properties = { "id": Property(identifier=True), From c26d1bca5623a003ef83d612e6febc9dc69f8ee0 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 13:14:22 -0400 Subject: [PATCH 094/379] Added documentation for `objects/domain.py`. (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation for `objects/domain.py`. ## ✔️ How to Test `pytest test` Ticket: TPT-1924 --- linode_api4/objects/domain.py | 71 +++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index 92be03974..82035b87e 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -3,6 +3,12 @@ class DomainRecord(DerivedBase): + """ + A single record on a Domain. + + API Documentation: https://www.linode.com/docs/api/domains/#domain-record-view + """ + api_endpoint = "/domains/{domain_id}/records/{id}" derived_url_path = "records" parent_id_name = "domain_id" @@ -26,6 +32,14 @@ class DomainRecord(DerivedBase): class Domain(Base): + """ + A single Domain that you have registered in Linode’s DNS Manager. + Linode is not a registrar, and in order for this Domain record to work + you must own the domain and point your registrar at Linode’s nameservers. + + API Documentation: https://www.linode.com/docs/api/domains/#domain-view + """ + api_endpoint = "/domains/{id}" properties = { "id": Property(identifier=True), @@ -46,6 +60,25 @@ class Domain(Base): } def record_create(self, record_type, **kwargs): + """ + Adds a new Domain Record to the zonefile this Domain represents. + Each domain can have up to 12,000 active records. + + API Documentation: https://www.linode.com/docs/api/domains/#domain-record-create + + :param record_type: The type of Record this is in the DNS system. Can be one of: + A, AAAA, NS, MX, CNAME, TXT, SRV, PTR, CAA. + :type: record_type: str + + :param kwargs: Additional optional parameters for creating a domain record. Valid parameters + are: name, target, priority, weight, port, service, protocol, ttl_sec. Descriptions + of these parameters can be found in the API Documentation above. + :type: record_type: dict + + :returns: The newly created Domain Record + :rtype: DomainRecord + """ + params = { "type": record_type, } @@ -65,6 +98,16 @@ def record_create(self, record_type, **kwargs): return zr def zone_file_view(self): + """ + Returns the zone file for the last rendered zone for the specified domain. + + API Documentation: https://www.linode.com/docs/api/domains/#domain-zone-file-view + + :returns: The zone file for the last rendered zone for the specified domain in the form + of a list of the lines of the zone file. + :rtype: List[str] + """ + result = self._client.get( "{}/zone-file".format(self.api_endpoint), model=self ) @@ -72,6 +115,17 @@ def zone_file_view(self): return result["zone_file"] def clone(self, domain: str): + """ + Clones a Domain and all associated DNS records from a Domain that is registered in Linode’s DNS manager. + + API Documentation: https://www.linode.com/docs/api/domains/#domain-clone + + :param domain: The new domain for the clone. Domain labels cannot be longer + than 63 characters and must conform to RFC1035. Domains must be + unique on Linode’s platform, including across different Linode + accounts; there cannot be two Domains representing the same domain. + :type: domain: str + """ params = {"domain": domain} self._client.post( @@ -79,6 +133,23 @@ def clone(self, domain: str): ) def domain_import(self, domain, remote_nameserver): + """ + Imports a domain zone from a remote nameserver. Your nameserver must + allow zone transfers (AXFR) from the following IPs: + - 96.126.114.97 + - 96.126.114.98 + - 2600:3c00::5e + = 2600:3c00::5f + + API Documentation: https://www.linode.com/docs/api/domains/#domain-import + + :param domain: The domain to import. + :type: domain: str + + :param remote_nameserver: The remote nameserver that allows zone transfers (AXFR). + :type: remote_nameserver: str + """ + params = { "domain": domain.domain if isinstance(domain, Domain) else domain, "remote_nameserver": remote_nameserver, From f2ecdb645ade9ffa679cdd7a4edb88384c591b2e Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 13:14:45 -0400 Subject: [PATCH 095/379] Added missing `mine` field to `StackScript` class (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added missing `mine` field to `StackScript` class ## ✔️ How to Test `pytest test` Ticket: TPT-1895 --- linode_api4/objects/linode.py | 1 + test/fixtures/linode_stackscripts_10079.json | 29 ++++++++++++++++++++ test/objects/linode_test.py | 23 +++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/linode_stackscripts_10079.json diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index cb2a533f6..cf78ec4fd 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1460,6 +1460,7 @@ class StackScript(Base): "deployments_total": Property(), "description": Property(mutable=True), "updated": Property(is_datetime=True), + "mine": Property(), } def _populate(self, json): diff --git a/test/fixtures/linode_stackscripts_10079.json b/test/fixtures/linode_stackscripts_10079.json new file mode 100644 index 000000000..bf0fef197 --- /dev/null +++ b/test/fixtures/linode_stackscripts_10079.json @@ -0,0 +1,29 @@ +{ + "created": "2018-01-01T00:01:01", + "deployments_active": 1, + "deployments_total": 12, + "description": "This StackScript installs and configures MySQL\n", + "id": 10079, + "images": [ + "linode/debian9", + "linode/debian8" + ], + "is_public": true, + "label": "a-stackscript", + "mine": true, + "rev_note": "Set up MySQL", + "script": "\"#!/bin/bash\"\n", + "updated": "2018-01-01T00:01:01", + "user_defined_fields": [ + { + "default": null, + "example": "hunter2", + "label": "Enter the password", + "manyOf": "avalue,anothervalue,thirdvalue", + "name": "DB_PASSWORD", + "oneOf": "avalue,anothervalue,thirdvalue" + } + ], + "user_gravatar_id": "a445b305abda30ebc766bc7fda037c37", + "username": "myuser" +} \ No newline at end of file diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index f0e02e06f..de3c5e20a 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.base import ClientBaseCase -from linode_api4.objects import Config, Disk, Image, Instance, Type +from linode_api4.objects import Config, Disk, Image, Instance, StackScript, Type class LinodeTest(ClientBaseCase): @@ -452,6 +452,27 @@ def test_get_config(self): self.assertIsNotNone(config.devices) +class StackScriptTest(ClientBaseCase): + """ + Tests the methods of the StackScript class. + """ + + def test_get_stackscript(self): + """ + Tests that a stackscript is loaded correctly by ID + """ + stackscript = StackScript(self.client, 10079) + + self.assertEqual(stackscript.id, 10079) + self.assertEqual(stackscript.deployments_active, 1) + self.assertEqual(stackscript.deployments_total, 12) + self.assertEqual(stackscript.rev_note, "Set up MySQL") + self.assertTrue(stackscript.mine) + self.assertTrue(stackscript.is_public) + self.assertIsNotNone(stackscript.user_defined_fields) + self.assertIsNotNone(stackscript.images) + + class TypeTest(ClientBaseCase): def test_get_types(self): """ From 2b0630674c1a21f65e5940dc8264aef3deaab5b5 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 13:40:59 -0400 Subject: [PATCH 096/379] Fixed doc links in profile group (#261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Fixed doc links in profile group ## ✔️ How to Test `pytest test` --- linode_api4/groups/profile.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index ef306666e..38c2f9695 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -68,6 +68,8 @@ def user_preferences(self): def security_questions(self): """ Returns a collection of security questions and their responses, if any, for your User Profile. + + API Documentation: https://www.linode.com/docs/api/profile/#security-questions-list """ result = self.client.get( @@ -118,7 +120,7 @@ def phone_number_delete(self): """ Delete the verified phone number for the User making this request. - API Documentation: https://api.linode.com/v4/profile/phone-number + API Documentation: https://www.linode.com/docs/api/profile/#phone-number-delete :returns: Returns True if the operation was successful. :rtype: bool @@ -141,7 +143,7 @@ def phone_number_verify(self, otp_code): Verify a phone number by confirming the one-time code received via SMS message after accessing the Phone Verification Code Send (POST /profile/phone-number) command. - API Documentation: https://api.linode.com/v4/profile/phone-number/verify + API Documentation: https://www.linode.com/docs/api/profile/#phone-number-verify :param otp_code: The one-time code received via SMS message after accessing the Phone Verification Code Send :type otp_code: str @@ -173,7 +175,7 @@ def phone_number_verification_code_send(self, iso_code, phone_number): """ Send a one-time verification code via SMS message to the submitted phone number. - API Documentation: https://api.linode.com/v4/profile/phone-number + API Documentation: https://www.linode.com/docs/api/profile/#phone-number-verification-code-send :param iso_code: The two-letter ISO 3166 country code associated with the phone number. :type iso_code: str From 06615dec28d28ef3f77a309795ade67bf3e365b8 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 14:41:32 -0400 Subject: [PATCH 097/379] doc: Add documentation to `objects/networking.py` (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds documentation to the `objects/networking.py` file. Blocked by #260 ## ✔️ How to Test ``` tox ``` --- linode_api4/objects/networking.py | 38 +++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 8ebd3a937..2d427d3b9 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -17,6 +17,12 @@ class IPv6Pool(Base): class IPv6Range(Base): + """ + An instance of a Linode IPv6 Range. + + API Documentation: https://www.linode.com/docs/api/networking/#ipv6-range-view + """ + api_endpoint = "/networking/ipv6/ranges/{range}" id_attribute = "range" @@ -87,6 +93,12 @@ class VLAN(Base): """ .. note:: At this time, the Linode API only supports listing VLANs. .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + An instance of a Linode VLAN. + VLANs provide a mechanism for secure communication between two or more Linodes that are assigned to the same VLAN. + VLANs are implicitly created during Instance or Instance Config creation. + + API Documentation: https://www.linode.com/docs/api/networking/#vlans-list """ api_endpoint = "/networking/vlans/{}" @@ -101,6 +113,12 @@ class VLAN(Base): class FirewallDevice(DerivedBase): + """ + An object representing the assignment between a Linode Firewall and another Linode resource. + + API Documentation: https://www.linode.com/docs/api/networking/#firewall-device-view + """ + api_endpoint = "/networking/firewalls/{firewall_id}/devices/{id}" derived_url_path = "devices" parent_id_name = "firewall_id" @@ -116,6 +134,10 @@ class FirewallDevice(DerivedBase): class Firewall(Base): """ .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + An instance of a Linode Cloud Firewall. + + API Documentation: https://www.linode.com/docs/api/networking/#firewall-view """ api_endpoint = "/networking/firewalls/{id}" @@ -133,7 +155,12 @@ class Firewall(Base): def update_rules(self, rules): """ - Sets the JSON rules for this Firewall + Sets the JSON rules for this Firewall. + + API Documentation: https://www.linode.com/docs/api/networking/#firewall-rules-update__request-samples + + :param rules: The rules to apply to this Firewall. + :type rules: dict """ self._client.put( "{}/rules".format(self.api_endpoint), model=self, data=rules @@ -142,7 +169,12 @@ def update_rules(self, rules): def get_rules(self): """ - Gets the JSON rules for this Firewall + Gets the JSON rules for this Firewall. + + API Documentation: https://www.linode.com/docs/api/networking/#firewall-rules-update__request-samples + + :returns: The rules that this Firewall is currently configured with. + :rtype: dict """ return self._client.get( "{}/rules".format(self.api_endpoint), model=self @@ -152,6 +184,8 @@ def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall + API Documentation: https://www.linode.com/docs/api/networking/#firewall-device-create + :param id: The ID of the entity to create a device for. :type id: int From d0f66433053c7bdf26db9f207852943333d946d9 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 14:42:06 -0400 Subject: [PATCH 098/379] doc: Add documentation to `objects/support.py` (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds documentation to the `objects/support.py` file. --- linode_api4/objects/support.py | 71 +++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index 42f57e60e..b9a817fca 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -13,6 +13,12 @@ class TicketReply(DerivedBase): + """ + A reply to a Support Ticket. + + API Documentation: https://www.linode.com/docs/api/support/#replies-list + """ + api_endpoint = "/support/tickets/{ticket_id}/replies" derived_url_path = "replies" parent_id_name = "ticket_id" @@ -28,6 +34,12 @@ class TicketReply(DerivedBase): class SupportTicket(Base): + """ + An objected representing a Linode Support Ticket. + + API Documentation: https://www.linode.com/docs/api/support/#replies-list + """ + api_endpoint = "/support/tickets/{id}" properties = { "id": Property(identifier=True), @@ -48,30 +60,69 @@ class SupportTicket(Base): @property def linode(self): + """ + If applicable, the Linode referenced in this ticket. + + :returns: The Linode referenced in this ticket. + :rtype: Optional[Instance] + """ + if self.entity and self.entity.type == "linode": return Instance(self._client, self.entity.id) return None @property def domain(self): + """ + If applicable, the Domain referenced in this ticket. + + :returns: The Domain referenced in this ticket. + :rtype: Optional[Domain] + """ + if self.entity and self.entity.type == "domain": return Domain(self._client, self.entity.id) return None @property def nodebalancer(self): + """ + If applicable, the NodeBalancer referenced in this ticket. + + :returns: The NodeBalancer referenced in this ticket. + :rtype: Optional[NodeBalancer] + """ + if self.entity and self.entity.type == "nodebalancer": return NodeBalancer(self._client, self.entity.id) return None @property def volume(self): + """ + If applicable, the Volume referenced in this ticket. + + :returns: The Volume referenced in this ticket. + :rtype: Optional[Volume] + """ + if self.entity and self.entity.type == "volume": return Volume(self._client, self.entity.id) return None def post_reply(self, description): - """ """ + """ + Adds a reply to an existing Support Ticket. + + API Documentation: https://www.linode.com/docs/api/support/#reply-create + + :param description: The content of this Support Ticket Reply. + :type description: str + + :returns: The new TicketReply object. + :rtype: Optional[TicketReply] + """ + result = self._client.post( "{}/replies".format(SupportTicket.api_endpoint), model=self, @@ -89,6 +140,18 @@ def post_reply(self, description): return r def upload_attachment(self, attachment): + """ + Uploads an attachment to an existing Support Ticket. + + API Documentation: https://www.linode.com/docs/api/support/#support-ticket-attachment-create + + :param attachment: A path to the file to upload as an attachment. + :type attachment: str + + :returns: Whether the upload operation was successful. + :rtype: bool + """ + content = None with open(attachment) as f: content = f.read() @@ -120,4 +183,10 @@ def upload_attachment(self, attachment): return True def support_ticket_close(self): + """ + Closes a Support Ticket. + + API Documentation: https://www.linode.com/docs/api/support/#support-ticket-close + """ + self._client.post("{}/close".format(self.api_endpoint), model=self) From 8a08c5fa9be4ef733bc1c5fd18baec226d489ddd Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 26 Apr 2023 16:53:49 -0400 Subject: [PATCH 099/379] Brought longview-related functionality to API parity (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Brought longview-related functionality to API parity ## ✔️ How to Test `pytest test` Ticket: TPT-1889 --- linode_api4/groups/longview.py | 43 +++++++++++++++++++++++++++++++- linode_api4/objects/longview.py | 17 +++++++++++++ test/fixtures/longview_plan.json | 9 +++++++ test/linode_client_test.py | 15 +++++++++++ test/objects/longview_test.py | 23 ++++++++++++++++- 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/longview_plan.json diff --git a/linode_api4/groups/longview.py b/linode_api4/groups/longview.py index 2bee86450..25b980ec7 100644 --- a/linode_api4/groups/longview.py +++ b/linode_api4/groups/longview.py @@ -1,6 +1,10 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import LongviewClient, LongviewSubscription +from linode_api4.objects import ( + LongviewClient, + LongviewPlan, + LongviewSubscription, +) class LongviewGroup(Group): @@ -60,3 +64,40 @@ def subscriptions(self, *filters): :rtype: PaginatedList of LongviewSubscription """ return self.client._get_and_filter(LongviewSubscription, *filters) + + def longview_plan_update(self, longview_subscription): + """ + Update your Longview plan to that of the given subcription ID. + + :param longview_subscription: The subscription ID for a particular Longview plan. + A value of null corresponds to Longview Free. + :type longview_subscription: str + + :returns: The updated Longview Plan + :rtype: LongviewPlan + """ + + if longview_subscription not in [ + "", + "longview-3", + "longview-10", + "longview-40", + "longview-100", + ]: + raise ValueError( + "Invalid longview plan subscription: {}".format( + longview_subscription + ) + ) + + params = {"longview_subscription": longview_subscription} + + result = self.client.post( + LongviewPlan.api_endpoint, model=self, data=params + ) + + plan = LongviewPlan(self.client, result["id"], result) + + plan.invalidate() + + return plan diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py index 71bfa389c..9d883693a 100644 --- a/linode_api4/objects/longview.py +++ b/linode_api4/objects/longview.py @@ -36,3 +36,20 @@ class LongviewSubscription(Base): "clients_included": Property(), "price": Property(), } + + +class LongviewPlan(Base): + """ + The current Longview Plan an account is using. + + API Documentation: https://www.linode.com/docs/api/longview/#longview-plan-view + """ + + api_endpoint = "/longview/plan" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "clients_included": Property(), + "price": Property(), + } diff --git a/test/fixtures/longview_plan.json b/test/fixtures/longview_plan.json new file mode 100644 index 000000000..f5f8503b2 --- /dev/null +++ b/test/fixtures/longview_plan.json @@ -0,0 +1,9 @@ +{ + "clients_included": 10, + "id": "longview-10", + "label": "Longview Pro 10 pack", + "price": { + "hourly": 0.06, + "monthly": 40 + } +} \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 5f40a0946..d41d9fc3a 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -502,6 +502,21 @@ def test_client_create_with_label(self): self.assertEqual(m.call_url, "/longview/clients") self.assertEqual(m.call_data, {"label": "test_client_1"}) + def test_update_plan(self): + """ + Tests that you can submit a correct longview plan update api request + """ + with self.mock_post("/longview/plan") as m: + result = self.client.longview.longview_plan_update("longview-100") + self.assertEqual(m.call_url, "/longview/plan") + self.assertEqual( + m.call_data["longview_subscription"], "longview-100" + ) + self.assertEqual(result.id, "longview-10") + self.assertEqual(result.clients_included, 10) + self.assertEqual(result.label, "Longview Pro 10 pack") + self.assertIsNotNone(result.price) + def test_get_subscriptions(self): """ Tests that Longview subscriptions can be retrieved diff --git a/test/objects/longview_test.py b/test/objects/longview_test.py index da28c5a4b..c5002eba8 100644 --- a/test/objects/longview_test.py +++ b/test/objects/longview_test.py @@ -1,10 +1,31 @@ from datetime import datetime from test.base import ClientBaseCase -from linode_api4.objects import LongviewClient, LongviewSubscription +from linode_api4.objects import ( + LongviewClient, + LongviewPlan, + LongviewSubscription, +) from linode_api4.objects.base import MappedObject +class LongviewPlanTest(ClientBaseCase): + """ + Tests methods of the LongviewPlan class + """ + + def test_get_plan(self): + """ + Tests that a plan is loaded correctly + """ + plan = LongviewPlan(self.client, "longview-10") + + self.assertEqual(plan.id, "longview-10") + self.assertEqual(plan.clients_included, 10) + self.assertEqual(plan.label, "Longview Pro 10 pack") + self.assertIsNotNone(plan.price) + + class LongviewClientTest(ClientBaseCase): """ Tests methods of the LongviewClient class From 9d1c5ffb8559cd9dcce010cf3bf77be1aad88097 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:01:16 -0400 Subject: [PATCH 100/379] TPT-1936: Added documentation for `object/volume.py` (#272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Added documentation for `object/volume.py` ## ✔️ How to Test `pytest test` Ticket: TPT-1936 --- linode_api4/objects/volume.py | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 1bd68110c..c40619187 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -3,6 +3,13 @@ class Volume(Base): + """ + A single Block Storage Volume. Block Storage Volumes are persistent storage devices + that can be attached to a Compute Instance and used to store any type of data. + + API Documentation: https://www.linode.com/docs/api/volumes/#volume-view + """ + api_endpoint = "/volumes/{id}" properties = { @@ -22,7 +29,17 @@ class Volume(Base): def attach(self, to_linode, config=None): """ - Attaches this Volume to the given Linode + Attaches this Volume to the given Linode. + + API Documentation: https://www.linode.com/docs/api/volumes/#volume-attach + + :param to_linode: The ID or object of the Linode to attach the volume to. + :type to_linode: Union[Instance, int] + + :param config: The ID or object of the Linode Config to include this Volume in. + Must belong to the Linode referenced by linode_id. + If not given, the last booted Config will be chosen. + :type config: Union[Config, int] """ result = self._client.post( "{}/attach".format(Volume.api_endpoint), @@ -50,6 +67,11 @@ def attach(self, to_linode, config=None): def detach(self): """ Detaches this Volume if it is attached + + API Documentation: https://www.linode.com/docs/api/volumes/#volume-detach + + :returns: Returns true if operation was successful + :rtype: bool """ self._client.post("{}/detach".format(Volume.api_endpoint), model=self) @@ -58,6 +80,14 @@ def detach(self): def resize(self, size): """ Resizes this Volume + + API Documentation: https://www.linode.com/docs/api/volumes/#volume-resize + + :param size: The Volume’s size, in GiB. + :type size: int + + :returns: Returns true if operation was successful + :rtype: bool """ result = self._client.post( "{}/resize".format(Volume.api_endpoint), @@ -73,9 +103,13 @@ def clone(self, label): """ Clones this volume to a new volume in the same region with the given label + API Documentation: https://www.linode.com/docs/api/volumes/#volume-clone + :param label: The label for the new volume. + :type label: str :returns: The new volume object. + :rtype: Volume """ result = self._client.post( "{}/clone".format(Volume.api_endpoint), From e7c40983247d039301af787848a05db15531e754 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:28:40 -0400 Subject: [PATCH 101/379] Bring OBJ-related functionality to API parity (#248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Add a list of obj-related functionality to API parity. The list of endpoints can be found in https://jira.linode.com/browse/TPT-1892. ## ✔️ How to Test `tox` --- linode_api4/groups/__init__.py | 2 +- linode_api4/groups/object_storage.py | 333 ++++++++++++ linode_api4/linode_client.py | 12 +- linode_api4/objects/__init__.py | 2 +- linode_api4/objects/object_storage.py | 478 +++++++++++++++++- test/fixtures.py | 2 +- test/fixtures/object-storage_buckets.json | 15 + .../object-storage_buckets_us-east-1.json | 15 + ...rage_buckets_us-east-1_example-bucket.json | 8 + ...s_us-east-1_example-bucket_object-acl.json | 4 + ..._us-east-1_example-bucket_object-list.json | 13 + ...s_us-east-1_example-bucket_object-url.json | 3 + ..._buckets_us-east-1_example-bucket_ssl.json | 3 + test/fixtures/object-storage_transfer.json | 3 + test/linode_client_test.py | 113 +++++ test/objects/object_storage_test.py | 259 ++++++++++ 16 files changed, 1257 insertions(+), 8 deletions(-) create mode 100644 linode_api4/groups/object_storage.py create mode 100644 test/fixtures/object-storage_buckets.json create mode 100644 test/fixtures/object-storage_buckets_us-east-1.json create mode 100644 test/fixtures/object-storage_buckets_us-east-1_example-bucket.json create mode 100644 test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json create mode 100644 test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json create mode 100644 test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json create mode 100644 test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json create mode 100644 test/fixtures/object-storage_transfer.json create mode 100644 test/objects/object_storage_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 74419a606..bf815fffd 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -10,7 +10,7 @@ from .longview import * from .networking import * from .nodebalancer import * -from .obj import * +from .object_storage import * from .profile import * from .region import * from .support import * diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py new file mode 100644 index 000000000..514e5e3a4 --- /dev/null +++ b/linode_api4/groups/object_storage.py @@ -0,0 +1,333 @@ +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + Base, + MappedObject, + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageCluster, + ObjectStorageKeys, +) +from linode_api4.util import drop_null_keys + + +class ObjectStorageGroup(Group): + """ + This group encapsulates all endpoints under /object-storage, including viewing + available clusters, buckets, and managing keys and TLS/SSL certs, etc. + """ + + def clusters(self, *filters): + """ + Returns a list of available Object Storage Clusters. You may filter + this query to return only Clusters that are available in a specific region:: + + us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") + + API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Object Storage Clusters that matched the query. + :rtype: PaginatedList of ObjectStorageCluster + """ + return self.client._get_and_filter(ObjectStorageCluster, *filters) + + def keys(self, *filters): + """ + Returns a list of Object Storage Keys active on this account. These keys + allow third-party applications to interact directly with Linode Object Storage. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list + + :param filters: Any number of filters to apply to this query. + + :returns: A list of Object Storage Keys that matched the query. + :rtype: PaginatedList of ObjectStorageKeys + """ + return self.client._get_and_filter(ObjectStorageKeys, *filters) + + def keys_create(self, label, bucket_access=None): + """ + Creates a new Object Storage keypair that may be used to interact directly + with Linode Object Storage in third-party applications. This response is + the only time that "secret_key" will be populated - be sure to capture its + value or it will be lost forever. + + If given, `bucket_access` will cause the new keys to be restricted to only + the specified level of access for the specified buckets. For example, to + create a keypair that can only access the "example" bucket in all clusters + (and assuming you own that bucket in every cluster), you might do this:: + + client = LinodeClient(TOKEN) + + # look up clusters + all_clusters = client.object_storage.clusters() + + new_keys = client.object_storage.keys_create( + "restricted-keys", + bucket_access=[ + client.object_storage.bucket_access(cluster, "example", "read_write") + for cluster in all_clusters + ], + ) + + To create a keypair that can only read from the bucket "example2" in the + "us-east-1" cluster (an assuming you own that bucket in that cluster), + you might do this:: + + client = LinodeClient(TOKEN) + new_keys_2 = client.object_storage.keys_create( + "restricted-keys-2", + bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), + ) + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create + + :param label: The label for this keypair, for identification only. + :type label: str + :param bucket_access: One or a list of dicts with keys "cluster," + "permissions", and "bucket_name". If given, the + resulting Object Storage keys will only have the + requested level of access to the requested buckets, + if they exist and are owned by you. See the provided + :any:`bucket_access` function for a convenient way + to create these dicts. + :type bucket_access: dict or list of dict + + :returns: The new keypair, with the secret key populated. + :rtype: ObjectStorageKeys + """ + params = {"label": label} + + if bucket_access is not None: + if not isinstance(bucket_access, list): + bucket_access = [bucket_access] + + ba = [ + { + "permissions": c.get("permissions"), + "bucket_name": c.get("bucket_name"), + "cluster": c.id + if "cluster" in c and issubclass(type(c["cluster"]), Base) + else c.get("cluster"), + } + for c in bucket_access + ] + + params["bucket_access"] = ba + + result = self.client.post("/object-storage/keys", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Object Storage Keys!", + json=result, + ) + + ret = ObjectStorageKeys(self.client, result["id"], result) + return ret + + def bucket_access(self, cluster, bucket_name, permissions): + return ObjectStorageBucket.access( + self, cluster, bucket_name, permissions + ) + + def cancel(self): + """ + Cancels Object Storage service. This may be a destructive operation. Once + cancelled, you will no longer receive the transfer for or be billed for + Object Storage, and all keys will be invalidated. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel + """ + self.client.post("/object-storage/cancel", data={}) + return True + + def transfer(self): + """ + The amount of outbound data transfer used by your account’s Object Storage buckets, + in bytes, for the current month’s billing cycle. Object Storage adds 1 terabyte + of outbound data transfer to your data transfer pool. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-transfer-view + + :returns: The amount of outbound data transfer used by your account’s Object + Storage buckets, in bytes, for the current month’s billing cycle. + :rtype: MappedObject + """ + result = self.client.get("/object-storage/transfer") + + if not "used" in result: + raise UnexpectedResponseError( + "Unexpected response when getting Transfer Pool!", + json=result, + ) + + return MappedObject(**result) + + def buckets(self, *filters): + """ + Returns a paginated list of all Object Storage Buckets that you own. + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-list + + :returns: A list of Object Storage Buckets that matched the query. + :rtype: PaginatedList of ObjectStorageBucket + """ + return self.client._get_and_filter(ObjectStorageBucket, *filters) + + def bucket_create( + self, + cluster, + label, + acl: ObjectStorageACL = ObjectStorageACL.PRIVATE, + cors_enabled=False, + ): + """ + Creates an Object Storage Bucket in the specified cluster. Accounts with + negative balances cannot access this command. If the bucket already exists + and is owned by you, this endpoint returns a 200 response with that bucket + as if it had just been created. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-create + + :param acl: The Access Control Level of the bucket using a canned ACL string. + For more fine-grained control of ACLs, use the S3 API directly. + :type acl: str + Enum: private,public-read,authenticated-read,public-read-write + + :param cluster: The ID of the Object Storage Cluster where this bucket + should be created. + :type cluster: str + + :param cors_enabled: If true, the bucket will be created with CORS enabled for + all origins. For more fine-grained controls of CORS, use + the S3 API directly. + :type cors_enabled: bool + + :param label: The name for this bucket. Must be unique in the cluster you are + creating the bucket in, or an error will be returned. Labels will + be reserved only for the cluster that active buckets are created + and stored in. If you want to reserve this bucket’s label in + another cluster, you must create a new bucket with the same label + in the new cluster. + :type label: str + + :returns: A Object Storage Buckets that created by user. + :rtype: ObjectStorageBucket + """ + cluster_id = ( + cluster.id if isinstance(cluster, ObjectStorageCluster) else cluster + ) + + params = { + "cluster": cluster_id, + "label": label, + "acl": acl, + "cors_enabled": cors_enabled, + } + + result = self.client.post("/object-storage/buckets", data=params) + + if not "label" in result or not "cluster" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Object Storage Bucket!", + json=result, + ) + + return ObjectStorageBucket( + self.client, result["label"], result["cluster"], result + ) + + def object_acl_config(self, cluster_id, bucket, name=None): + return ObjectStorageBucket( + self.client, bucket, cluster_id + ).object_acl_config(name) + + def object_acl_config_update( + self, cluster_id, bucket, acl: ObjectStorageACL, name + ): + return ObjectStorageBucket( + self.client, bucket, cluster_id + ).object_acl_config_update(acl, name) + + def object_url_create( + self, + cluster_id, + bucket, + method, + name, + content_type=None, + expires_in=3600, + ): + """ + Creates a pre-signed URL to access a single Object in a bucket. + This can be used to share objects, and also to create/delete objects by using + the appropriate HTTP method in your request body’s method parameter. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-url-create + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param content_type: The expected Content-type header of the request this + signed URL will be valid for. If provided, the + Content-type header must be sent with the request when + this URL is used, and must be the same as it was when + the signed URL was created. + Required for all methods except “GET” or “DELETE”. + :type content_type: str + + :param expires_in: How long this signed URL will be valid for, in seconds. + If omitted, the URL will be valid for 3600 seconds (1 hour). Defaults to 3600. + :type expires_in: int 360..86400 + + :param method: The HTTP method allowed to be used with the pre-signed URL. + :type method: str + + :param name: The name of the object that will be accessed with the pre-signed + URL. This object need not exist, and no error will be returned + if it doesn’t. This behavior is useful for generating pre-signed + URLs to upload new objects to by setting the method to “PUT”. + :type name: str + + :returns: The signed URL to perform the request at. + :rtype: MappedObject + """ + if method not in ("GET", "DELETE") and content_type is None: + raise ValueError( + "Content-type header is missing for the current method! It's required for all methods except GET or DELETE." + ) + params = { + "method": method, + "name": name, + "expires_in": expires_in, + "content_type": content_type, + } + + result = self.client.post( + "/object-storage/buckets/{}/{}/object-url".format( + cluster_id, bucket + ), + data=drop_null_keys(params), + ) + + if not "url" in result: + raise UnexpectedResponseError( + "Unexpected response when creating the access url of an object!", + json=result, + ) + + return MappedObject(**result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 8d85515be..1357ef887 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -364,7 +364,7 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): ) # helper functions - def _get_and_filter(self, obj_type, *filters): + def _get_and_filter(self, obj_type, *filters, endpoint=None): parsed_filters = None if filters: if len(filters) > 1: @@ -374,6 +374,10 @@ def _get_and_filter(self, obj_type, *filters): else: parsed_filters = filters[0].dct - return self._get_objects( - obj_type.api_list(), obj_type, filters=parsed_filters - ) + # Use sepcified endpoint + if endpoint: + return self._get_objects(endpoint, obj_type, filters=parsed_filters) + else: + return self._get_objects( + obj_type.api_list(), obj_type, filters=parsed_filters + ) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 33c2800e8..256e0193d 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -14,6 +14,6 @@ from .profile import * from .longview import * from .tag import Tag -from .object_storage import ObjectStorageCluster, ObjectStorageKeys +from .object_storage import * from .lke import * from .database import * diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index b2be22dfe..694cde15e 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,4 +1,456 @@ -from linode_api4.objects import Base, DerivedBase, Image, Property, Region +from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects import ( + Base, + DerivedBase, + MappedObject, + Property, + Region, +) +from linode_api4.util import drop_null_keys + + +class ObjectStorageACL: + PRIVATE = "private" + PUBLIC_READ = "public-read" + AUTHENTICATED_READ = "authenticated-read" + PUBLIC_READ_WRITE = "public-read-write" + CUSTOM = "custom" + + +class ObjectStorageBucket(DerivedBase): + """ + A bucket where objects are stored in. + """ + + api_endpoint = "/object-storage/buckets/{cluster}/{label}" + parent_id_name = "cluster" + id_attribute = "label" + + properties = { + "cluster": Property(), + "created": Property(is_datetime=True), + "hostname": Property(), + "label": Property(), + "objects": Property(), + "size": Property(), + } + + @classmethod + def api_list(cls): + """ + Override this method to return the correct URL that will produce + a list of JSON objects of this class' type - Object Storage Bucket. + """ + return "/".join(cls.api_endpoint.split("/")[:-2]) + + @classmethod + def make_instance(cls, id, client, parent_id=None, json=None): + """ + Override this method to pass in the parent_id from the _raw_json object + when it's available. + """ + if json is None: + return None + if parent_id is None and json["cluster"]: + parent_id = json["cluster"] + + if parent_id: + return super().make(id, client, cls, parent_id=parent_id, json=json) + else: + raise UnexpectedResponseError( + "Unexpected json response when making a new Object Storage Bucket instance." + ) + + def access_modify( + self, + acl: ObjectStorageACL = None, + cors_enabled=None, + ): + """ + Allows changing basic Cross-origin Resource Sharing (CORS) and Access Control + Level (ACL) settings. Only allows enabling/disabling CORS for all origins, + and/or setting canned ACLs. For more fine-grained control of both systems, + please use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-modify + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param acl: The Access Control Level of the bucket using a canned ACL string. + For more fine-grained control of ACLs, use the S3 API directly. + :type acl: str + Enum: private,public-read,authenticated-read,public-read-write + + :param cors_enabled: If true, the bucket will be created with CORS enabled for + all origins. For more fine-grained controls of CORS, use + the S3 API directly. + :type cors_enabled: bool + """ + params = { + "acl": acl, + "cors_enabled": cors_enabled, + } + + resp = self._client.post( + "/object-storage/buckets/{}/{}/access".format( + self.cluster, self.id + ), + data=drop_null_keys(params), + ) + + if "errors" in resp: + raise UnexpectedResponseError( + "Unexpected response when modifying the access to a bucket!", + json=resp, + ) + return True + + def access_update( + self, + acl: ObjectStorageACL = None, + cors_enabled=None, + ): + """ + Allows changing basic Cross-origin Resource Sharing (CORS) and Access Control + Level (ACL) settings. Only allows enabling/disabling CORS for all origins, + and/or setting canned ACLs. For more fine-grained control of both systems, + please use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-update + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param acl: The Access Control Level of the bucket using a canned ACL string. + For more fine-grained control of ACLs, use the S3 API directly. + :type acl: str + Enum: private,public-read,authenticated-read,public-read-write + + :param cors_enabled: If true, the bucket will be created with CORS enabled for + all origins. For more fine-grained controls of CORS, + use the S3 API directly. + :type cors_enabled: bool + """ + params = { + "acl": acl, + "cors_enabled": cors_enabled, + } + + resp = self._client.put( + "/object-storage/buckets/{}/{}/access".format( + self.cluster, self.id + ), + data=drop_null_keys(params), + ) + + if "errors" in resp: + raise UnexpectedResponseError( + "Unexpected response when updating the access to a bucket!", + json=resp, + ) + return True + + def ssl_cert_delete(self): + """ + Deletes this Object Storage bucket’s user uploaded TLS/SSL certificate + and private key. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-delete + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :returns: True if the TLS/SSL certificate and private key in the bucket were successfully deleted. + :rtype: bool + """ + + resp = self._client.delete( + "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id) + ) + + if "error" in resp: + raise UnexpectedResponseError( + "Unexpected response when deleting a bucket!", + json=resp, + ) + return True + + def ssl_cert(self): + """ + Returns a result object which wraps a bool value indicating + if this bucket has a corresponding TLS/SSL certificate that + was uploaded by an Account user. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-view + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :returns: A result object which has a bool field indicating if this Bucket has a corresponding + TLS/SSL certificate that was uploaded by an Account user. + :rtype: MappedObject + """ + result = self._client.get( + "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id) + ) + + if not "ssl" in result: + raise UnexpectedResponseError( + "Unexpected response when getting the TLS/SSL certs indicator of a bucket!", + json=result, + ) + + return MappedObject(**result) + + def ssl_cert_upload(self, certificate, private_key): + """ + Upload a TLS/SSL certificate and private key to be served when you + visit your Object Storage bucket via HTTPS. Your TLS/SSL certificate and + private key are stored encrypted at rest. + + To replace an expired certificate, delete your current certificate and + upload a new one. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-upload + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param certificate: Your Base64 encoded and PEM formatted SSL certificate. + Line breaks must be represented as “\n” in the string + for requests (but not when using the Linode CLI) + :type certificate: str + + :param private_key: The private key associated with this TLS/SSL certificate. + Line breaks must be represented as “\n” in the string + for requests (but not when using the Linode CLI) + :type private_key: str + + :returns: A result object which has a bool field indicating if this Bucket has a corresponding + TLS/SSL certificate that was uploaded by an Account user. + :rtype: MappedObject + """ + params = { + "certificate": certificate, + "private_key": private_key, + } + result = self._client.post( + "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id), + data=params, + ) + + if not "ssl" in result: + raise UnexpectedResponseError( + "Unexpected response when uploading TLS/SSL certs!", + json=result, + ) + + return MappedObject(**result) + + def contents( + self, + marker=None, + delimiter=None, + prefix=None, + page_size=100, + ): + """ + Returns the contents of a bucket. + The contents are paginated using a marker, which is the name of the last object + on the previous page. Objects may be filtered by prefix and delimiter as well; + see Query Parameters for more information. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-contents-list + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param marker: The “marker” for this request, which can be used to paginate + through large buckets. Its value should be the value of the + next_marker property returned with the last page. Listing + bucket contents does not support arbitrary page access. See the + next_marker property in the responses section for more details. + :type marker: str + + :param delimiter: The delimiter for object names; if given, object names will + be returned up to the first occurrence of this character. + This is most commonly used with the / character to allow + bucket transversal in a manner similar to a filesystem, + however any delimiter may be used. Use in conjunction with + prefix to see object names past the first occurrence of + the delimiter. + :type delimiter: str + + :param prefix: Filters objects returned to only those whose name start with + the given prefix. Commonly used in conjunction with delimiter + to allow transversal of bucket contents in a manner similar to + a filesystem. + :type perfix: str + + :param page_size: The number of items to return per page. Defaults to 100. + :type page_size: int 25..500 + + :returns: A list of the MappedObject of the requested bucket's contents. + :rtype: [MappedObject] + """ + params = { + "marker": marker, + "delimiter": delimiter, + "prefix": prefix, + "page_size": page_size, + } + result = self._client.get( + "/object-storage/buckets/{}/{}/object-list".format( + self.cluster, self.id + ), + data=drop_null_keys(params), + ) + + if not "data" in result: + raise UnexpectedResponseError( + "Unexpected response when getting the contents of a bucket!", + json=result, + ) + + return [MappedObject(**c) for c in result["data"]] + + def object_acl_config(self, name=None): + """ + View an Object’s configured Access Control List (ACL) in this Object Storage + bucket. ACLs define who can access your buckets and objects and specify the + level of access granted to those users. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-view + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param name: The name of the object for which to retrieve its Access Control + List (ACL). Use the Object Storage Bucket Contents List endpoint + to access all object names in a bucket. + :type name: str + + :returns: The Object's canned ACL and policy. + :rtype: MappedObject + """ + params = { + "name": name, + } + + result = self._client.get( + f"{ObjectStorageBucket.api_endpoint}/object-acl", + model=self, + data=drop_null_keys(params), + ) + + if not "acl" in result: + raise UnexpectedResponseError( + "Unexpected response when viewing Object’s configured ACL!", + json=result, + ) + + return MappedObject(**result) + + def object_acl_config_update(self, acl: ObjectStorageACL, name): + """ + Update an Object’s configured Access Control List (ACL) in this Object Storage + bucket. ACLs define who can access your buckets and objects and specify the + level of access granted to those users. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-update + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :param bucket: The bucket name. + :type bucket: str + + :param acl: The Access Control Level of the bucket, as a canned ACL string. + For more fine-grained control of ACLs, use the S3 API directly. + :type acl: str + Enum: private,public-read,authenticated-read,public-read-write,custom + + :param name: The name of the object for which to retrieve its Access Control + List (ACL). Use the Object Storage Bucket Contents List endpoint + to access all object names in a bucket. + :type name: str + + :returns: The Object's canned ACL and policy. + :rtype: MappedObject + """ + params = { + "acl": acl, + "name": name, + } + + result = self._client.put( + f"{ObjectStorageBucket.api_endpoint}/object-acl", + model=self, + data=params, + ) + + if not "acl" in result: + raise UnexpectedResponseError( + "Unexpected response when updating Object’s configured ACL!", + json=result, + ) + + return MappedObject(**result) + + def access(self, cluster, bucket_name, permissions): + """ + Returns a dict formatted to be included in the `bucket_access` argument + of :any:`keys_create`. See the docs for that method for an example of + usage. + + :param cluster: The Object Storage cluster to grant access in. + :type cluster: :any:`ObjectStorageCluster` or str + :param bucket_name: The name of the bucket to grant access to. + :type bucket_name: str + :param permissions: The permissions to grant. Should be one of "read_only" + or "read_write". + :type permissions: str + + :returns: A dict formatted correctly for specifying bucket access for + new keys. + :rtype: dict + """ + return { + "cluster": cluster, + "bucket_name": bucket_name, + "permissions": permissions, + } class ObjectStorageCluster(Base): @@ -16,6 +468,28 @@ class ObjectStorageCluster(Base): "static_site_domain": Property(), } + def buckets_in_cluster(self, *filters): + """ + Returns a list of Buckets in this cluster belonging to this Account. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-in-cluster-list + + :param cluster_id: The ID of the cluster this bucket exists in. + :type cluster_id: str + + :returns: A list of Object Storage Buckets that in the requested cluster. + :rtype: PaginatedList of ObjectStorageBucket + """ + + return self._client._get_and_filter( + ObjectStorageBucket, + *filters, + endpoint="/object-storage/buckets/{}".format(self.id), + ) + class ObjectStorageKeys(Base): """ @@ -29,4 +503,6 @@ class ObjectStorageKeys(Base): "label": Property(mutable=True), "access_key": Property(), "secret_key": Property(), + "bucket_access": Property(), + "limited": Property(), } diff --git a/test/fixtures.py b/test/fixtures.py index 94f3cee51..a4609b22c 100644 --- a/test/fixtures.py +++ b/test/fixtures.py @@ -41,5 +41,5 @@ def _load_fixtures(self): if "results" in data: # this is a paginated response for obj in data["data"]: - if "id" in obj: # tags don't have ids + if "id" in obj: # tags, obj-buckets don't have ids self.fixtures[fixture_url + "/" + str(obj["id"])] = obj diff --git a/test/fixtures/object-storage_buckets.json b/test/fixtures/object-storage_buckets.json new file mode 100644 index 000000000..f99a944a6 --- /dev/null +++ b/test/fixtures/object-storage_buckets.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "cluster": "us-east-1", + "created": "2019-01-01T01:23:45", + "hostname": "example-bucket.us-east-1.linodeobjects.com", + "label": "example-bucket", + "objects": 4, + "size": 188318981 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1.json b/test/fixtures/object-storage_buckets_us-east-1.json new file mode 100644 index 000000000..f99a944a6 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east-1.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "cluster": "us-east-1", + "created": "2019-01-01T01:23:45", + "hostname": "example-bucket.us-east-1.linodeobjects.com", + "label": "example-bucket", + "objects": 4, + "size": 188318981 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json new file mode 100644 index 000000000..b8c9450b6 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json @@ -0,0 +1,8 @@ +{ + "cluster": "us-east-1", + "created": "2019-01-01T01:23:45", + "hostname": "example-bucket.us-east-1.linodeobjects.com", + "label": "example-bucket", + "objects": 4, + "size": 188318981 +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json new file mode 100644 index 000000000..a9b9aaf34 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-acl.json @@ -0,0 +1,4 @@ +{ + "acl": "public-read", + "acl_xml": "..." +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json new file mode 100644 index 000000000..6d92be5e0 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-list.json @@ -0,0 +1,13 @@ +{ + "data": [ + { + "etag": "9f254c71e28e033bf9e0e5262e3e72ab", + "is_truncated": true, + "last_modified": "2019-01-01T01:23:45", + "name": "example", + "next_marker": "bd021c21-e734-4823-97a4-58b41c2cd4c8.892602.184", + "owner": "bfc70ab2-e3d4-42a4-ad55-83921822270c", + "size": 123 + } + ] +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json new file mode 100644 index 000000000..de617779b --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_object-url.json @@ -0,0 +1,3 @@ +{ + "url": "https://us-east-1.linodeobjects.com/example-bucket/example?Signature=qr98TEucCntPgEG%2BsZQGDsJg93c%3D&Expires=1567609905&AWSAccessKeyId=G4YAF81XWY61DQM94SE0" +} \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json new file mode 100644 index 000000000..e16ebc332 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket_ssl.json @@ -0,0 +1,3 @@ +{ + "ssl": true +} \ No newline at end of file diff --git a/test/fixtures/object-storage_transfer.json b/test/fixtures/object-storage_transfer.json new file mode 100644 index 000000000..ae1968e66 --- /dev/null +++ b/test/fixtures/object-storage_transfer.json @@ -0,0 +1,3 @@ +{ + "used": 12956600198 +} \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index d41d9fc3a..35378d482 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -6,6 +6,10 @@ from linode_api4 import ApiError, LinodeClient, LongviewSubscription from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress +from linode_api4.objects.object_storage import ( + ObjectStorageACL, + ObjectStorageCluster, +) class LinodeClientGeneralTest(ClientBaseCase): @@ -840,6 +844,115 @@ def test_keys_create(self): self.assertEqual(m.call_url, "/object-storage/keys") self.assertEqual(m.call_data, {"label": "object-storage-key-1"}) + def test_transfer(self): + """ + Test that you can get the amount of outbound data transfer + used by your account’s Object Storage buckets + """ + object_storage_transfer_url = "/object-storage/transfer" + + with self.mock_get(object_storage_transfer_url) as m: + result = self.client.object_storage.transfer() + self.assertEqual(result.used, 12956600198) + self.assertEqual(m.call_url, object_storage_transfer_url) + + def test_buckets(self): + """ + Test that Object Storage Buckets can be reterived + """ + object_storage_buckets_url = "/object-storage/buckets" + + with self.mock_get(object_storage_buckets_url) as m: + buckets = self.client.object_storage.buckets() + self.assertIsNotNone(buckets) + bucket = buckets[0] + + self.assertEqual(m.call_url, object_storage_buckets_url) + self.assertEqual(bucket.cluster, "us-east-1") + self.assertEqual( + bucket.created, + datetime( + year=2019, month=1, day=1, hour=1, minute=23, second=45 + ), + ) + self.assertEqual( + bucket.hostname, "example-bucket.us-east-1.linodeobjects.com" + ) + self.assertEqual(bucket.label, "example-bucket") + self.assertEqual(bucket.objects, 4) + self.assertEqual(bucket.size, 188318981) + + def test_bucket_create(self): + """ + Test that you can create a Object Storage Bucket + """ + # buckets don't work like a normal RESTful collection, so we have to do this + with self.mock_post( + {"label": "example-bucket", "cluster": "us-east-1"} + ) as m: + b = self.client.object_storage.bucket_create( + "us-east-1", "example-bucket", ObjectStorageACL.PRIVATE, True + ) + self.assertIsNotNone(b) + self.assertEqual(m.call_url, "/object-storage/buckets") + self.assertEqual( + m.call_data, + { + "label": "example-bucket", + "cluster": "us-east-1", + "cors_enabled": True, + "acl": "private", + }, + ) + + """ + Test that you can create a Object Storage Bucket passing a Cluster object + """ + with self.mock_post( + {"label": "example-bucket", "cluster": "us-east-1"} + ) as m: + cluster = ObjectStorageCluster(self.client, "us-east-1") + b = self.client.object_storage.bucket_create( + cluster, "example-bucket", "private", True + ) + self.assertIsNotNone(b) + self.assertEqual(m.call_url, "/object-storage/buckets") + self.assertEqual( + m.call_data, + { + "label": "example-bucket", + "cluster": "us-east-1", + "cors_enabled": True, + "acl": "private", + }, + ) + + def test_object_url_create(self): + """ + Test that you can create pre-signed URL to access a single Object in a bucket. + """ + object_url_create_url = ( + "/object-storage/buckets/us-east-1/example-bucket/object-url" + ) + with self.mock_post(object_url_create_url) as m: + result = self.client.object_storage.object_url_create( + "us-east-1", "example-bucket", "GET", "example" + ) + self.assertIsNotNone(result) + self.assertEqual(m.call_url, object_url_create_url) + self.assertEqual( + result.url, + "https://us-east-1.linodeobjects.com/example-bucket/example?Signature=qr98TEucCntPgEG%2BsZQGDsJg93c%3D&Expires=1567609905&AWSAccessKeyId=G4YAF81XWY61DQM94SE0", + ) + self.assertEqual( + m.call_data, + { + "method": "GET", + "name": "example", + "expires_in": 3600, + }, + ) + class NetworkingGroupTest(ClientBaseCase): """ diff --git a/test/objects/object_storage_test.py b/test/objects/object_storage_test.py new file mode 100644 index 000000000..a2c9219e2 --- /dev/null +++ b/test/objects/object_storage_test.py @@ -0,0 +1,259 @@ +from datetime import datetime +from test.base import ClientBaseCase + +from linode_api4.objects import ( + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageCluster, +) + + +class ObjectStorageTest(ClientBaseCase): + """ + Test the methods of the ObjectStorage + """ + + def test_object_storage_bucket_api_get(self): + object_storage_bucket_api_get_url = ( + "/object-storage/buckets/us-east-1/example-bucket" + ) + with self.mock_get(object_storage_bucket_api_get_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + self.assertEqual(object_storage_bucket.cluster, "us-east-1") + self.assertEqual(object_storage_bucket.label, "example-bucket") + self.assertEqual( + object_storage_bucket.created, + datetime( + year=2019, month=1, day=1, hour=1, minute=23, second=45 + ), + ) + self.assertEqual( + object_storage_bucket.hostname, + "example-bucket.us-east-1.linodeobjects.com", + ) + self.assertEqual(object_storage_bucket.objects, 4) + self.assertEqual(object_storage_bucket.size, 188318981) + self.assertEqual(m.call_url, object_storage_bucket_api_get_url) + + def test_object_storage_bucket_delete(self): + object_storage_bucket_delete_url = ( + "/object-storage/buckets/us-east-1/example-bucket" + ) + with self.mock_delete() as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + object_storage_bucket.delete() + self.assertEqual(m.call_url, object_storage_bucket_delete_url) + + def test_bucket_access_modify(self): + """ + Test that you can modify bucket access settings. + """ + bucket_access_modify_url = ( + "/object-storage/buckets/us-east-1/example-bucket/access" + ) + with self.mock_post({}) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + object_storage_bucket.access_modify(ObjectStorageACL.PRIVATE, True) + self.assertEqual( + m.call_data, + { + "acl": "private", + "cors_enabled": True, + }, + ) + self.assertEqual(m.call_url, bucket_access_modify_url) + + def test_bucket_access_update(self): + """ + Test that you can update bucket access settings. + """ + bucket_access_update_url = ( + "/object-storage/buckets/us-east-1/example-bucket/access" + ) + with self.mock_put({}) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + object_storage_bucket.access_update(ObjectStorageACL.PRIVATE, True) + self.assertEqual( + m.call_data, + { + "acl": "private", + "cors_enabled": True, + }, + ) + self.assertEqual(m.call_url, bucket_access_update_url) + + def test_buckets_in_cluster(self): + """ + Test that Object Storage Buckets in a specified cluster can be reterived + """ + buckets_in_cluster_url = "/object-storage/buckets/us-east-1" + with self.mock_get(buckets_in_cluster_url) as m: + cluster = ObjectStorageCluster(self.client, "us-east-1") + buckets = cluster.buckets_in_cluster() + self.assertIsNotNone(buckets) + bucket = buckets[0] + + self.assertEqual(m.call_url, buckets_in_cluster_url) + self.assertEqual(bucket.cluster, "us-east-1") + self.assertEqual( + bucket.created, + datetime( + year=2019, month=1, day=1, hour=1, minute=23, second=45 + ), + ) + self.assertEqual( + bucket.hostname, "example-bucket.us-east-1.linodeobjects.com" + ) + self.assertEqual(bucket.label, "example-bucket") + self.assertEqual(bucket.objects, 4) + self.assertEqual(bucket.size, 188318981) + + def test_ssl_cert_delete(self): + """ + Test that you can delete the TLS/SSL certificate and private key of a bucket. + """ + ssl_cert_delete_url = ( + "/object-storage/buckets/us-east-1/example-bucket/ssl" + ) + with self.mock_delete() as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + object_storage_bucket.ssl_cert_delete() + self.assertEqual(m.call_url, ssl_cert_delete_url) + + def test_ssl_cert(self): + """ + Test tha you can get a bool value indicating if this bucket + has a corresponding TLS/SSL certificate. + """ + ssl_cert_url = "/object-storage/buckets/us-east-1/example-bucket/ssl" + with self.mock_get(ssl_cert_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + result = object_storage_bucket.ssl_cert() + self.assertIsNotNone(result) + self.assertEqual(m.call_url, ssl_cert_url) + self.assertEqual(result.ssl, True) + + def test_ssl_cert_upload(self): + """ + Test that you can upload a TLS/SSL cert. + """ + ssl_cert_upload_url = ( + "/object-storage/buckets/us-east-1/example-bucket/ssl" + ) + with self.mock_post(ssl_cert_upload_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + result = object_storage_bucket.ssl_cert_upload( + "-----BEGIN CERTIFICATE-----\nCERTIFICATE_INFORMATION\n-----END CERTIFICATE-----", + "-----BEGIN PRIVATE KEY-----\nPRIVATE_KEY_INFORMATION\n-----END PRIVATE KEY-----", + ) + self.assertIsNotNone(result) + self.assertEqual(m.call_url, ssl_cert_upload_url) + self.assertEqual(result.ssl, True) + self.assertEqual( + m.call_data, + { + "certificate": "-----BEGIN CERTIFICATE-----\nCERTIFICATE_INFORMATION\n-----END CERTIFICATE-----", + "private_key": "-----BEGIN PRIVATE KEY-----\nPRIVATE_KEY_INFORMATION\n-----END PRIVATE KEY-----", + }, + ) + + def test_contents(self): + """ + Test that you can get the contents of a bucket. + """ + bucket_contents_url = ( + "/object-storage/buckets/us-east-1/example-bucket/object-list" + ) + with self.mock_get(bucket_contents_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + contents = object_storage_bucket.contents() + self.assertIsNotNone(contents) + content = contents[0] + + self.assertEqual(m.call_url, bucket_contents_url) + self.assertEqual(content.etag, "9f254c71e28e033bf9e0e5262e3e72ab") + self.assertEqual(content.is_truncated, True) + self.assertEqual(content.last_modified, "2019-01-01T01:23:45") + self.assertEqual(content.name, "example") + self.assertEqual( + content.next_marker, + "bd021c21-e734-4823-97a4-58b41c2cd4c8.892602.184", + ) + self.assertEqual( + content.owner, "bfc70ab2-e3d4-42a4-ad55-83921822270c" + ) + self.assertEqual(content.size, 123) + self.assertEqual( + m.call_data, + { + "page_size": 100, + }, + ) + + def test_object_acl_config(self): + """ + Test that you can view an Object’s configured Access Control List (ACL) in this Object Storage bucket. + """ + object_acl_config_url = ( + "/object-storage/buckets/us-east-1/example-bucket/object-acl" + ) + with self.mock_get(object_acl_config_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + acl = object_storage_bucket.object_acl_config("example") + self.assertEqual(m.call_url, object_acl_config_url) + self.assertEqual(acl.acl, "public-read") + self.assertEqual( + acl.acl_xml, "..." + ) + self.assertEqual( + m.call_data, + { + "name": "example", + }, + ) + + def test_object_acl_config_update(self): + """ + Test that you can update an Object’s configured Access Control List (ACL) in this Object Storage bucket. + """ + object_acl_config_update_url = ( + "/object-storage/buckets/us-east-1/example-bucket/object-acl" + ) + with self.mock_put(object_acl_config_update_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east-1" + ) + acl = object_storage_bucket.object_acl_config_update( + ObjectStorageACL.PUBLIC_READ, + "example", + ) + self.assertEqual(m.call_url, object_acl_config_update_url) + self.assertEqual(acl.acl, "public-read") + self.assertEqual( + acl.acl_xml, "..." + ) + self.assertEqual( + m.call_data, + { + "acl": "public-read", + "name": "example", + }, + ) From 6bbea843dce47aece7643b641f3a3091a87b559e Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:31:04 -0400 Subject: [PATCH 102/379] Add API documentations to objects/object_storage (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Adding missing API documentations to the existing resources in object_storage. https://jira.linode.com/browse/TPT-1931 --- linode_api4/objects/object_storage.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 694cde15e..5b434ff2d 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -456,6 +456,8 @@ def access(self, cluster, bucket_name, permissions): class ObjectStorageCluster(Base): """ A cluster where Object Storage is available. + + API documentation: https://www.linode.com/docs/api/object-storage/#cluster-view """ api_endpoint = "/object-storage/clusters/{id}" @@ -494,6 +496,8 @@ def buckets_in_cluster(self, *filters): class ObjectStorageKeys(Base): """ A keypair that allows third-party applications to access Linode Object Storage. + + API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-view """ api_endpoint = "/object-storage/keys/{id}" From cfdc8a18427e05bbb26435b2f53c9780e4a55cce Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Fri, 28 Apr 2023 13:53:15 -0400 Subject: [PATCH 103/379] Add documentation to objects/nodebalancer (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Add documentations to endpoint wrapper methods and resources classes under objects/nodebalancer. https://jira.linode.com/browse/TPT-1930 --- linode_api4/objects/nodebalancer.py | 58 +++++++++++++++++++++++++-- linode_api4/objects/object_storage.py | 2 + 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 712586695..9b08bf769 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -6,6 +6,12 @@ class NodeBalancerNode(DerivedBase): + """ + The information about a single Node, a backend for this NodeBalancer’s configured port. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#node-view + """ + api_endpoint = ( "/nodebalancers/{nodebalancer_id}/configs/{config_id}/nodes/{id}" ) @@ -45,6 +51,12 @@ def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None): class NodeBalancerConfig(DerivedBase): + """ + The configuration information for a single port of this NodeBalancer. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#config-view + """ + api_endpoint = "/nodebalancers/{nodebalancer_id}/configs/{id}" derived_url_path = "configs" parent_id_name = "nodebalancer_id" @@ -77,6 +89,14 @@ def nodes(self): """ This is a special derived_class relationship because NodeBalancerNode is the only api object that requires two parent_ids + + Returns a paginated list of NodeBalancer nodes associated with this Config. + These are the backends that will be sent traffic for this port. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#nodes-list + + :returns: A paginated list of NodeBalancer nodes. + :rtype: PaginatedList of NodeBalancerNode """ if not hasattr(self, "_nodes"): base_url = "{}/{}".format( @@ -95,6 +115,24 @@ def nodes(self): return self._nodes def node_create(self, label, address, **kwargs): + """ + Creates a NodeBalancer Node, a backend that can accept traffic for this + NodeBalancer Config. Nodes are routed requests on the configured port based + on their status. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#node-create + + :param address: The private IP Address where this backend can be reached. + This must be a private IP address. + :type address: str + + :param label: The label for this node. This is for display purposes only. + Must have a length between 2 and 32 characters. + :type label: str + + :returns: The node which is created successfully. + :rtype: NodeBalancerNode + """ params = { "label": label, "address": address, @@ -152,6 +190,12 @@ def load_ssl_data(self, cert_file, key_file): class NodeBalancer(Base): + """ + A single NodeBalancer you can access. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-view + """ + api_endpoint = "/nodebalancers/{id}" properties = { "id": Property(identifier=True), @@ -168,10 +212,18 @@ class NodeBalancer(Base): } # create derived objects - def config_create(self, label=None, **kwargs): + def config_create(self, **kwargs): + """ + Creates a NodeBalancer Config, which allows the NodeBalancer to accept traffic + on a new port. You will need to add NodeBalancer Nodes to the new Config before + it can actually serve requests. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#config-create + + :returns: The config that created successfully. + :rtype: NodeBalancerConfig + """ params = kwargs - if label: - params["label"] = label result = self._client.post( "{}/configs".format(NodeBalancer.api_endpoint), diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 5b434ff2d..d96256c6b 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -20,6 +20,8 @@ class ObjectStorageACL: class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. + + API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-view """ api_endpoint = "/object-storage/buckets/{cluster}/{label}" From 1610f833146b9e0f4e58ace2b85960219f06270f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 28 Apr 2023 14:55:39 -0400 Subject: [PATCH 104/379] Fix `upload_attachment` (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description closes #17 Fix `upload_attachment` method of `SupportTicket` class. ## ✔️ How to Test Manually test it --- linode_api4/objects/support.py | 35 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index b9a817fca..78d1d86d1 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -1,3 +1,6 @@ +from pathlib import Path +from typing import Union + import requests from linode_api4.errors import ApiError, UnexpectedResponseError @@ -139,7 +142,7 @@ def post_reply(self, description): r = TicketReply(self._client, result["id"], self.id, result) return r - def upload_attachment(self, attachment): + def upload_attachment(self, attachment: Union[Path, str]): """ Uploads an attachment to an existing Support Ticket. @@ -151,27 +154,25 @@ def upload_attachment(self, attachment): :returns: Whether the upload operation was successful. :rtype: bool """ + if not isinstance(attachment, Path): + attachment = Path(attachment) - content = None - with open(attachment) as f: - content = f.read() - - if not content: - raise ValueError("Nothing to upload!") + if not attachment.exists(): + raise ValueError("File not exist, nothing to upload.") headers = { - "Authorization": "token {}".format(self._client.token), - "Content-type": "multipart/form-data", + "Authorization": "Bearer {}".format(self._client.token), } - result = requests.post( - "{}{}/attachments".format( - self._client.base_url, - SupportTicket.api_endpoint.format(id=self.id), - ), - headers=headers, - files=content, - ) + with open(attachment, "rb") as f: + result = requests.post( + "{}{}/attachments".format( + self._client.base_url, + SupportTicket.api_endpoint.format(id=self.id), + ), + headers=headers, + files={"file": f}, + ) if not result.status_code == 200: errors = [] From 125a4b2e4901c8be1ab94838d19232959c335aca Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 3 May 2023 17:09:18 -0400 Subject: [PATCH 105/379] Fix global IPv6 parsing for Instance.ips --- linode_api4/objects/linode.py | 4 ++-- test/fixtures/linode_instances_123_ips.json | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index cf78ec4fd..68d3e7879 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -11,7 +11,7 @@ from linode_api4.objects import Base, DerivedBase, Image, Property, Region from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute -from linode_api4.objects.networking import IPAddress, IPv6Pool +from linode_api4.objects.networking import IPAddress, IPv6Range from linode_api4.paginated_list import PaginatedList PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation @@ -465,7 +465,7 @@ def ips(self): result["ipv6"]["link_local"], ) - pools = [IPv6Pool(self._client, result["ipv6"]["global"]["range"])] + pools = [IPv6Range(self._client, r["range"]) for r in result["ipv6"]["global"]] ips = MappedObject( **{ diff --git a/test/fixtures/linode_instances_123_ips.json b/test/fixtures/linode_instances_123_ips.json index 8b9e64af0..147020248 100644 --- a/test/fixtures/linode_instances_123_ips.json +++ b/test/fixtures/linode_instances_123_ips.json @@ -54,12 +54,14 @@ ] }, "ipv6": { - "global": { - "prefix": 124, - "range": "2600:3c01::2:5000:0", - "region": "us-east", - "route_target": "2600:3c01::2:5000:f" - }, + "global": [ + { + "prefix": 124, + "range": "2600:3c01::2:5000:0", + "region": "us-east", + "route_target": "2600:3c01::2:5000:f" + } + ], "link_local": { "address": "fe80::f03c:91ff:fe24:3a2f", "gateway": "fe80::1", From b9f7703ea46216ecc859e9fe7fd56aa0e48ac763 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Wed, 3 May 2023 17:13:53 -0400 Subject: [PATCH 106/379] fix lint --- linode_api4/objects/linode.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 68d3e7879..6027c34ac 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -465,7 +465,10 @@ def ips(self): result["ipv6"]["link_local"], ) - pools = [IPv6Range(self._client, r["range"]) for r in result["ipv6"]["global"]] + pools = [ + IPv6Range(self._client, r["range"]) + for r in result["ipv6"]["global"] + ] ips = MappedObject( **{ From 367286929afbcb4804a8cd0291548d7b7a0ef52d Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Fri, 5 May 2023 12:45:41 -0400 Subject: [PATCH 107/379] Bring nodebalancer-related functionality to API parity (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Add missing fields and methods of node balancer to bring it to API parity. https://jira.linode.com/browse/TPT-1891 ## ✔️ How to Test `tox` --- linode_api4/objects/nodebalancer.py | 65 +++++++++++++++++- ...ebalancers_12345_configs_4567_rebuild.json | 25 +++++++ test/fixtures/nodebalancers_12345_stats.json | 16 +++++ test/objects/nodebalancers_test.py | 68 ++++++++++++++++++- 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/nodebalancers_12345_configs_4567_rebuild.json create mode 100644 test/fixtures/nodebalancers_12345_stats.json diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 9b08bf769..2a32204e5 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,7 +1,13 @@ import os from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects import ( + Base, + DerivedBase, + MappedObject, + Property, + Region, +) from linode_api4.objects.networking import IPAddress @@ -209,6 +215,8 @@ class NodeBalancer(Base): "ipv6": Property(), "region": Property(slug_relationship=Region), "configs": Property(derived_class=NodeBalancerConfig), + "transfer": Property(), + "tags": Property(), } # create derived objects @@ -239,3 +247,58 @@ def config_create(self, **kwargs): c = NodeBalancerConfig(self._client, result["id"], self.id, result) return c + + def config_rebuild(self, config_id, nodes, **kwargs): + """ + Rebuilds a NodeBalancer Config and its Nodes that you have permission to modify. + Use this command to update a NodeBalancer’s Config and Nodes with a single request. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#config-rebuild + + :param config_id: The ID of the Config to access. + :type config_id: int + + :param nodes: The NodeBalancer Node(s) that serve this Config. + :type nodes: [{ address: str, id: int, label: str, mode: str, weight: int }] + + :returns: A nodebalancer config that rebuilt successfully. + :rtype: NodeBalancerConfig + """ + params = { + "nodes": nodes, + } + params.update(kwargs) + + result = self._client.post( + "{}/configs/{}/rebuild".format( + NodeBalancer.api_endpoint, config_id + ), + model=self, + data=params, + ) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response rebuilding config!", json=result + ) + + return NodeBalancerConfig(self._client, result["id"], self.id, result) + + def statistics(self): + """ + Returns detailed statistics about the requested NodeBalancer. + + API documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-statistics-view + + :returns: The requested stats. + :rtype: MappedObject + """ + result = self._client.get( + "{}/stats".format(NodeBalancer.api_endpoint), model=self + ) + + if not "title" in result: + raise UnexpectedResponseError( + "Unexpected response generating stats!", json=result + ) + return MappedObject(**result) diff --git a/test/fixtures/nodebalancers_12345_configs_4567_rebuild.json b/test/fixtures/nodebalancers_12345_configs_4567_rebuild.json new file mode 100644 index 000000000..d4b6f0cbe --- /dev/null +++ b/test/fixtures/nodebalancers_12345_configs_4567_rebuild.json @@ -0,0 +1,25 @@ +{ + "algorithm": "roundrobin", + "check": "http_body", + "check_attempts": 3, + "check_body": "it works", + "check_interval": 90, + "check_passive": true, + "check_path": "/test", + "check_timeout": 10, + "cipher_suite": "recommended", + "id": 4567, + "nodebalancer_id": 12345, + "nodes_status": { + "down": 0, + "up": 4 + }, + "port": 80, + "protocol": "http", + "proxy_protocol": "none", + "ssl_cert": "", + "ssl_commonname": "www.example.com", + "ssl_fingerprint": "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13", + "ssl_key": "", + "stickiness": "http_cookie" +} \ No newline at end of file diff --git a/test/fixtures/nodebalancers_12345_stats.json b/test/fixtures/nodebalancers_12345_stats.json new file mode 100644 index 000000000..5e1824609 --- /dev/null +++ b/test/fixtures/nodebalancers_12345_stats.json @@ -0,0 +1,16 @@ +{ + "data": { + "connections": [ + null + ], + "traffic": { + "in": [ + null + ], + "out": [ + null + ] + } + }, + "title": "linode.com - balancer12345 (12345) - day (5 min avg)" +} \ No newline at end of file diff --git a/test/objects/nodebalancers_test.py b/test/objects/nodebalancers_test.py index 4450cdcdc..822012286 100644 --- a/test/objects/nodebalancers_test.py +++ b/test/objects/nodebalancers_test.py @@ -1,6 +1,10 @@ from test.base import ClientBaseCase -from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode +from linode_api4.objects import ( + NodeBalancer, + NodeBalancerConfig, + NodeBalancerNode, +) from linode_api4.objects.base import MappedObject @@ -128,3 +132,65 @@ def test_delete_node(self): self.assertEqual( m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321" ) + + def test_config_rebuild(self): + """ + Test that you can rebuild the cofig of a node balancer. + """ + config_rebuild_url = "/nodebalancers/12345/configs/4567/rebuild" + with self.mock_post(config_rebuild_url) as m: + nb = NodeBalancer(self.client, 12345) + nodes = [ + { + "id": 54321, + "address": "192.168.210.120:80", + "label": "node1", + "weight": 50, + "mode": "accept", + } + ] + + result = nb.config_rebuild( + 4567, + nodes, + port=1234, + protocol="https", + algorithm="roundrobin", + ) + self.assertIsNotNone(result) + self.assertEqual(result.id, 4567) + self.assertEqual(result.nodebalancer_id, 12345) + self.assertEqual(m.call_url, config_rebuild_url) + self.assertEqual( + m.call_data, + { + "port": 1234, + "protocol": "https", + "algorithm": "roundrobin", + "nodes": [ + { + "id": 54321, + "address": "192.168.210.120:80", + "label": "node1", + "weight": 50, + "mode": "accept", + }, + ], + }, + ) + + def test_statistics(self): + """ + Test that you can get the statistics about the requested NodeBalancer. + """ + statistics_url = "/nodebalancers/12345/stats" + with self.mock_get(statistics_url) as m: + nb = NodeBalancer(self.client, 12345) + result = nb.statistics() + + self.assertIsNotNone(result) + self.assertEqual( + result.title, + "linode.com - balancer12345 (12345) - day (5 min avg)", + ) + self.assertEqual(m.call_url, statistics_url) From 233a899103191a5675d300c731d7e4dccf1f89af Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 9 May 2023 13:11:44 -0400 Subject: [PATCH 108/379] Remove mongodb references (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Removing all MongoDB references in the codebase. --- linode_api4/groups/database.py | 65 ------ linode_api4/objects/database.py | 150 -------------- .../fixtures/databases_mongodb_instances.json | 45 ----- ...tabases_mongodb_instances_123_backups.json | 13 -- ...odb_instances_123_backups_456_restore.json | 1 - ...ses_mongodb_instances_123_credentials.json | 4 - ...ngodb_instances_123_credentials_reset.json | 1 - ...databases_mongodb_instances_123_patch.json | 1 - .../databases_mongodb_instances_123_ssl.json | 3 - test/fixtures/databases_types.json | 9 - test/objects/database_test.py | 188 +----------------- 11 files changed, 2 insertions(+), 478 deletions(-) delete mode 100644 test/fixtures/databases_mongodb_instances.json delete mode 100644 test/fixtures/databases_mongodb_instances_123_backups.json delete mode 100644 test/fixtures/databases_mongodb_instances_123_backups_456_restore.json delete mode 100644 test/fixtures/databases_mongodb_instances_123_credentials.json delete mode 100644 test/fixtures/databases_mongodb_instances_123_credentials_reset.json delete mode 100644 test/fixtures/databases_mongodb_instances_123_patch.json delete mode 100644 test/fixtures/databases_mongodb_instances_123_ssl.json diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 494c30eba..af4a7a819 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -5,7 +5,6 @@ Database, DatabaseEngine, DatabaseType, - MongoDBDatabase, MySQLDatabase, PostgreSQLDatabase, ) @@ -200,67 +199,3 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): d = PostgreSQLDatabase(self.client, result["id"], result) return d - - def mongodb_instances(self, *filters): - """ - Returns a list of Managed MongoDB Databases active on this account. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-databases-list - - :param filters: Any number of filters to apply to this query. - - :returns: A list of MongoDB databases that matched the query. - :rtype: PaginatedList of MongoDBDatabase - """ - return self.client._get_and_filter(MongoDBDatabase, *filters) - - def mongodb_create(self, label, region, engine, ltype, **kwargs): - """ - Creates an :any:`MongoDBDatabase` on this account with - the given label, region, engine, and node type. For example:: - - client = LinodeClient(TOKEN) - - # look up Region and Types to use. In this example I'm just using - # the first ones returned. - region = client.regions().first() - node_type = client.database.types()[0] - engine = client.database.engines(DatabaseEngine.engine == 'mongodb')[0] - - new_database = client.database.mongodb_create( - "example-database", - region, - engine.id, - type.id - ) - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-create - - :param label: The name for this cluster - :type label: str - :param region: The region to deploy this cluster in - :type region: str or Region - :param engine: The engine to deploy this cluster with - :type engine: str or Engine - :param ltype: The Linode Type to use for this cluster - :type ltype: str or Type - """ - - params = { - "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - } - params.update(kwargs) - - result = self.client.post("/databases/mongodb/instances", data=params) - - if "id" not in result: - raise UnexpectedResponseError( - "Unexpected response when creating MongoDB Database", - json=result, - ) - - d = MongoDBDatabase(self.client, result["id"], result) - return d diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 4b50fa12c..efddee0c7 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -39,7 +39,6 @@ class DatabaseEngine(Base): - MySQL - PostgreSQL - - MongoDB API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engine-view """ @@ -89,7 +88,6 @@ def restore(self): API Documentation: - - MongoDB: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-restore - MySQL: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-restore - PostgreSQL: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-restore """ @@ -109,16 +107,6 @@ class MySQLDatabaseBackup(DatabaseBackup): api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" -class MongoDBDatabaseBackup(DatabaseBackup): - """ - A backup for an accessible Managed MongoDB Database. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-view - """ - - api_endpoint = "/databases/mongodb/instances/{database_id}/backups/{id}" - - class PostgreSQLDatabaseBackup(DatabaseBackup): """ A backup for an accessible Managed PostgreSQL Database. @@ -397,147 +385,9 @@ def invalidate(self): Base.invalidate(self) -class MongoDBDatabase(Base): - """ - An accessible Managed MongoDB Database. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-view - """ - - api_endpoint = "/databases/mongodb/instances/{id}" - - properties = { - "id": Property(identifier=True), - "label": Property(mutable=True), - "allow_list": Property(mutable=True), - "backups": Property(derived_class=MongoDBDatabaseBackup), - "cluster_size": Property(), - "compression_type": Property(), - "created": Property(is_datetime=True), - "encrypted": Property(), - "engine": Property(), - "hosts": Property(), - "peers": Property(), - "port": Property(), - "region": Property(), - "replica_set": Property(), - "ssl_connection": Property(), - "status": Property(volatile=True), - "storage_engine": Property(), - "type": Property(), - "updated": Property(volatile=True, is_datetime=True), - "updates": Property(mutable=True), - "version": Property(), - } - - @property - def credentials(self): - """ - Display the root username and password for an accessible Managed MongoDB Database. - The Database must have an active status to perform this command. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-credentials-view - - :returns: MappedObject containing credntials for this DB - :rtype: MappedObject - """ - - if not hasattr(self, "_credentials"): - resp = self._client.get( - "{}/credentials".format(MongoDBDatabase.api_endpoint), - model=self, - ) - self._set("_credentials", MappedObject(**resp)) - - return self._credentials - - @property - def ssl(self): - """ - Display the SSL CA certificate for an accessible Managed MongoDB Database. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-ssl-certificate-view - - :returns: MappedObject containing SSL CA certificate for this DB - :rtype: MappedObject - """ - - if not hasattr(self, "_ssl"): - resp = self._client.get( - "{}/ssl".format(MongoDBDatabase.api_endpoint), model=self - ) - self._set("_ssl", MappedObject(**resp)) - - return self._ssl - - def credentials_reset(self): - """ - Reset the root password for a Managed MongoDB Database. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-credentials-reset - - :returns: Response from the API call to reset credentials - :rtype: dict - """ - - self.invalidate() - - return self._client.post( - "{}/credentials/reset".format(MongoDBDatabase.api_endpoint), - model=self, - ) - - def patch(self): - """ - Apply security patches and updates to the underlying operating system of the Managed MongoDB Database. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-patch - - :returns: Response from the API call to apply security patches - :rtype: dict - """ - - self.invalidate() - - return self._client.post( - "{}/patch".format(MongoDBDatabase.api_endpoint), model=self - ) - - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed MongoDB Database. - - API Documentation: https://www.linode.com/docs/api/databases/#managed-mongodb-database-backup-snapshot-create - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(MongoDBDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - - def invalidate(self): - """ - Clear out cached properties. - """ - - for attr in ["_ssl", "_credentials"]: - if hasattr(self, attr): - delattr(self, attr) - - Base.invalidate(self) - - ENGINE_TYPE_TRANSLATION = { "mysql": MySQLDatabase, "postgresql": PostgreSQLDatabase, - "mongodb": MongoDBDatabase, } diff --git a/test/fixtures/databases_mongodb_instances.json b/test/fixtures/databases_mongodb_instances.json deleted file mode 100644 index 383a1b295..000000000 --- a/test/fixtures/databases_mongodb_instances.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "data": [ - { - "allow_list": [ - "203.0.113.1/32", - "192.0.1.0/24" - ], - "cluster_size": 3, - "compression_type": "none", - "created": "2022-01-01T00:01:01", - "encrypted": false, - "engine": "mongodb", - "hosts": { - "primary": "lin-0000-0000.servers.linodedb.net", - "secondary": null - }, - "id": 123, - "label": "example-db", - "peers": [ - "lin-0000-0000.servers.linodedb.net", - "lin-0000-0001.servers.linodedb.net", - "lin-0000-0002.servers.linodedb.net" - ], - "port": 27017, - "region": "us-east", - "replica_set": null, - "ssl_connection": true, - "status": "active", - "storage_engine": "wiredtiger", - "type": "g6-dedicated-2", - "updated": "2022-01-01T00:01:01", - "updates": { - "day_of_week": 1, - "duration": 3, - "frequency": "weekly", - "hour_of_day": 0, - "week_of_month": null - }, - "version": "4.4.10" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_backups.json b/test/fixtures/databases_mongodb_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_mongodb_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_backups_456_restore.json b/test/fixtures/databases_mongodb_instances_123_backups_456_restore.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_mongodb_instances_123_backups_456_restore.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_credentials.json b/test/fixtures/databases_mongodb_instances_123_credentials.json deleted file mode 100644 index 217c27c00..000000000 --- a/test/fixtures/databases_mongodb_instances_123_credentials.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "password": "s3cur3P@ssw0rd", - "username": "linroot" -} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_credentials_reset.json b/test/fixtures/databases_mongodb_instances_123_credentials_reset.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_mongodb_instances_123_credentials_reset.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_patch.json b/test/fixtures/databases_mongodb_instances_123_patch.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_mongodb_instances_123_patch.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/fixtures/databases_mongodb_instances_123_ssl.json b/test/fixtures/databases_mongodb_instances_123_ssl.json deleted file mode 100644 index a331c5cd6..000000000 --- a/test/fixtures/databases_mongodb_instances_123_ssl.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ca_certificate": "LS0tLS1CRUdJ...==" -} \ No newline at end of file diff --git a/test/fixtures/databases_types.json b/test/fixtures/databases_types.json index fec5c0234..d85232764 100644 --- a/test/fixtures/databases_types.json +++ b/test/fixtures/databases_types.json @@ -5,15 +5,6 @@ "deprecated": false, "disk": 25600, "engines": { - "mongodb": [ - { - "price": { - "hourly": 0.03, - "monthly": 20 - }, - "quantity": 1 - } - ], "mysql": [ { "price": { diff --git a/test/objects/database_test.py b/test/objects/database_test.py index a70f3ae54..e32368d33 100644 --- a/test/objects/database_test.py +++ b/test/objects/database_test.py @@ -1,6 +1,6 @@ from test.base import ClientBaseCase -from linode_api4 import MongoDBDatabase, PostgreSQLDatabase +from linode_api4 import PostgreSQLDatabase from linode_api4.objects import MySQLDatabase @@ -18,7 +18,7 @@ def test_get_types(self): self.assertEqual(len(types), 1) self.assertEqual(types[0].type_class, "nanode") self.assertEqual(types[0].id, "g6-nanode-1") - self.assertEqual(types[0].engines.mongodb[0].price.monthly, 20) + self.assertEqual(types[0].engines.mysql[0].price.monthly, 20) def test_get_engines(self): """ @@ -440,187 +440,3 @@ def test_reset_credentials(self): m.call_url, "/databases/postgresql/instances/123/credentials/reset", ) - - -class MongoDBDatabaseTest(ClientBaseCase): - """ - Tests methods of the MongoDBDatabase class - """ - - def test_get_instances(self): - """ - Test that database types are properly handled - """ - dbs = self.client.database.mongodb_instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].compression_type, "none") - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "mongodb") - self.assertEqual( - dbs[0].hosts.primary, "lin-0000-0000.servers.linodedb.net" - ) - self.assertEqual(dbs[0].hosts.secondary, None) - self.assertEqual(len(dbs[0].peers), 3) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "4.4.10") - - def test_create(self): - """ - Test that MongoDB databases can be created - """ - - with self.mock_post("/databases/mongodb/instances") as m: - # We don't care about errors here; we just want to - # validate the request. - try: - self.client.database.mongodb_create( - "cool", - "us-southeast", - "mongodb/4.4.10", - "g6-standard-1", - cluster_size=3, - ) - except Exception: - pass - - self.assertEqual(m.method, "post") - self.assertEqual(m.call_url, "/databases/mongodb/instances") - self.assertEqual(m.call_data["label"], "cool") - self.assertEqual(m.call_data["region"], "us-southeast") - self.assertEqual(m.call_data["engine"], "mongodb/4.4.10") - self.assertEqual(m.call_data["type"], "g6-standard-1") - self.assertEqual(m.call_data["cluster_size"], 3) - - def test_update(self): - """ - Test that the MongoDB database can be updated - """ - - with self.mock_put("/databases/mongodb/instances/123") as m: - new_allow_list = ["192.168.0.1/32"] - - db = MongoDBDatabase(self.client, 123) - - db.updates.day_of_week = 2 - db.allow_list = new_allow_list - db.label = "cool" - - db.save() - - self.assertEqual(m.method, "put") - self.assertEqual(m.call_url, "/databases/mongodb/instances/123") - self.assertEqual(m.call_data["label"], "cool") - self.assertEqual(m.call_data["updates"]["day_of_week"], 2) - self.assertEqual(m.call_data["allow_list"], new_allow_list) - - def test_list_backups(self): - """ - Test that MongoDB backups list properly - """ - - db = MongoDBDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that MongoDB database backups can be created - """ - - with self.mock_post("/databases/mongodb/instances/123/backups") as m: - db = MongoDBDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="secondary") - except Exception: - pass - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mongodb/instances/123/backups" - ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") - - def test_backup_restore(self): - """ - Test that MongoDB database backups can be restored - """ - - with self.mock_post( - "/databases/mongodb/instances/123/backups/456/restore" - ) as m: - db = MongoDBDatabase(self.client, 123) - - db.backups[0].restore() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, - "/databases/mongodb/instances/123/backups/456/restore", - ) - - def test_patch(self): - """ - Test MongoDB Database patching logic. - """ - with self.mock_post("/databases/mongodb/instances/123/patch") as m: - db = MongoDBDatabase(self.client, 123) - - db.patch() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mongodb/instances/123/patch" - ) - - def test_get_ssl(self): - """ - Test MongoDB SSL cert logic - """ - db = MongoDBDatabase(self.client, 123) - - ssl = db.ssl - - self.assertEqual(ssl.ca_certificate, "LS0tLS1CRUdJ...==") - - def test_get_credentials(self): - """ - Test MongoDB credentials logic - """ - db = MongoDBDatabase(self.client, 123) - - creds = db.credentials - - self.assertEqual(creds.password, "s3cur3P@ssw0rd") - self.assertEqual(creds.username, "linroot") - - def test_reset_credentials(self): - """ - Test resetting MongoDB credentials - """ - with self.mock_post( - "/databases/mongodb/instances/123/credentials/reset" - ) as m: - db = MongoDBDatabase(self.client, 123) - - db.credentials_reset() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mongodb/instances/123/credentials/reset" - ) From d511f44816b685f4b333b94a7484fc84f078d134 Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Tue, 9 May 2023 14:57:59 -0400 Subject: [PATCH 109/379] TPT 1880: Fixed issue with updating non-populated NodeBalancerNode (#277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Previously, attempting to update a non-populated NodeBalancerNode would result in the update actually updating the resource silently. This change fixes the issue. ## ✔️ How to Test `pytest test` Note: Since this change deals with making updates to real resources as opposed to fixtures, it cannot be tested using mocks and must therefore be tested manually. To do this, first create a NodeBalancerNode in your Linode account if one does not already exist, and then run this python script and verify that the weight of the node was actually updated. ``` #!/usr/bin/env python3 from linode_api4 import LinodeClient from linode_api4.objects import NodeBalancerNode client = LinodeClient() node = NodeBalancerNode(client, , , ) node.weight = 60 node.save() ``` Resolves #97 --- linode_api4/objects/base.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 26859087c..be72a395e 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -210,9 +210,26 @@ def save(self, force=True) -> bool: if not force and not self._changed: return False - resp = self._client.put( - type(self).api_endpoint, model=self, data=self._serialize() - ) + data = None + if not self._populated: + data = { + a: object.__getattribute__(self, a) + for a in type(self).properties + if type(self).properties[a].mutable + and object.__getattribute__(self, a) is not None + } + + for key, value in data.items(): + if ( + isinstance(value, ExplicitNullValue) + or value == ExplicitNullValue + ): + data[key] = None + + else: + data = self._serialize() + + resp = self._client.put(type(self).api_endpoint, model=self, data=data) if "error" in resp: return False From 49d08013c6bbd576e234560dd513da84a9e0ed54 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 10 May 2023 11:14:03 -0400 Subject: [PATCH 110/379] Make `backups` and `watchdog_enabled` mutable; implement nested MappedObject `dict` Conversion (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Since `backups` and `watchdog_enabled` fields of the Linode `Instance` the are mutable by the API, we may also want it be mutable in Python SDK. Original `Base.dict` property doesn't support converting nested MappedObject to dict, so I also implemented it in this PR. closes #281 --- .gitignore | 3 ++- linode_api4/objects/base.py | 13 ++++++++++++- linode_api4/objects/linode.py | 4 ++-- test/objects/linode_test.py | 5 +++++ test/objects/mapped_object_test.py | 21 +++++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 test/objects/mapped_object_test.py diff --git a/.gitignore b/.gitignore index 57450459a..6043f36e5 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ docs/_build/* .pytest_cache/* .tox/* venv -baked_version \ No newline at end of file +baked_version +.vscode diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index be72a395e..ba613c76d 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -90,7 +90,18 @@ def __repr__(self): @property def dict(self): - return dict(self.__dict__) + result = vars(self).copy() + cls = type(self) + + for k, v in result.items(): + if isinstance(v, cls): + result[k] = v.dict + elif isinstance(v, list): + result[k] = [ + item.dict if isinstance(item, cls) else item for item in v + ] + + return result class Base(object, metaclass=FilterableMetaclass): diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index cf78ec4fd..6c22f9b53 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -403,14 +403,14 @@ class Instance(Base): "disks": Property(derived_class=Disk), "configs": Property(derived_class=Config), "type": Property(slug_relationship=Type), - "backups": Property(), + "backups": Property(mutable=True), "ipv4": Property(), "ipv6": Property(), "hypervisor": Property(), "specs": Property(), "tags": Property(mutable=True), "host_uuid": Property(), - "watchdog_enabled": Property(), + "watchdog_enabled": Property(mutable=True), } @property diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index de3c5e20a..2bea2f4f9 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -147,9 +147,14 @@ def test_update_linode(self): "network_out": 5, "transfer_quota": 80, }, + "backups": { + "enabled": True, + "schedule": {"day": "Scheduling", "window": "W02"}, + }, "label": "NewLinodeLabel", "group": "new_group", "tags": ["something"], + "watchdog_enabled": True, }, ) diff --git a/test/objects/mapped_object_test.py b/test/objects/mapped_object_test.py new file mode 100644 index 000000000..87284af8f --- /dev/null +++ b/test/objects/mapped_object_test.py @@ -0,0 +1,21 @@ +from unittest import TestCase + +from linode_api4 import MappedObject + + +class MappedObjectCase(TestCase): + def test_mapped_object_dict(self): + test_dict = { + "key1": 1, + "key2": "2", + "key3": 3.3, + "key4": [41, "42", {"key4-3": "43"}], + "key5": { + "key5-1": 1, + "key5-2": {"key5-2-1": {"key5-2-1-1": 1}}, + "key5-3": [{"key5-3-1": 531}, {"key5-3-2": 532}], + }, + } + + mapped_obj = MappedObject(**test_dict) + self.assertEqual(mapped_obj.dict, test_dict) From 1fe8c7b11d91c831030bfc03bd20133f631814b2 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 17 May 2023 11:34:16 -0400 Subject: [PATCH 111/379] fix: Correct `root_pass` field in `Instance.reset_instance_root_password(...)` (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change corrects the `root_pass` request body field to match the field expected by the API: https://www.linode.com/docs/api/linode-instances/#linode-root-password-reset ## ✔️ How to Test ``` tox ``` --- linode_api4/objects/linode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 6c22f9b53..5b57569a3 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -563,7 +563,7 @@ def reset_instance_root_password(self, root_password=None): rpass = Instance.generate_root_password() params = { - "password": rpass, + "root_pass": rpass, } self._client.post( From 93dd8de5bf256bef42bd270df638c38130aaf1ad Mon Sep 17 00:00:00 2001 From: ezilber-akamai <122484180+ezilber-akamai@users.noreply.github.com> Date: Wed, 17 May 2023 13:08:04 -0400 Subject: [PATCH 112/379] `Domain.clone()` returns a new `Domain` object (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description `Domain.clone()` returns a new `Domain` object. ## ✔️ How to Test `pytest test` Ticket: TPT-2019 --- linode_api4/objects/domain.py | 4 +++- test/fixtures/domains_12345_clone.json | 2 +- test/objects/domain_test.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index 82035b87e..38778c78d 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -128,10 +128,12 @@ def clone(self, domain: str): """ params = {"domain": domain} - self._client.post( + result = self._client.post( "{}/clone".format(self.api_endpoint), model=self, data=params ) + return Domain(self, result["id"], result) + def domain_import(self, domain, remote_nameserver): """ Imports a domain zone from a remote nameserver. Your nameserver must diff --git a/test/fixtures/domains_12345_clone.json b/test/fixtures/domains_12345_clone.json index faf3e7c28..5ded999b6 100644 --- a/test/fixtures/domains_12345_clone.json +++ b/test/fixtures/domains_12345_clone.json @@ -4,7 +4,7 @@ "domain": "example.org", "expire_sec": 300, "group": null, - "id": 1234, + "id": 12345, "master_ips": [], "refresh_sec": 300, "retry_sec": 300, diff --git a/test/objects/domain_test.py b/test/objects/domain_test.py index 003058af8..805e8e7f9 100644 --- a/test/objects/domain_test.py +++ b/test/objects/domain_test.py @@ -35,9 +35,10 @@ def test_clone(self): domain = Domain(self.client, 12345) with self.mock_post("/domains/12345/clone") as m: - domain.clone("example.org") + clone = domain.clone("example.org") self.assertEqual(m.call_url, "/domains/12345/clone") self.assertEqual(m.call_data["domain"], "example.org") + self.assertEqual(clone.id, 12345) def test_import(self): domain = Domain(self.client, 12345) From dc592c706518abc1c6d4b4c88b074970e5375d5f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 22 May 2023 11:21:50 -0400 Subject: [PATCH 113/379] Rename pools to ranges in `Instance.ips` (#288) --- linode_api4/objects/linode.py | 4 ++-- test/objects/linode_test.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 6027c34ac..4527d013a 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -465,7 +465,7 @@ def ips(self): result["ipv6"]["link_local"], ) - pools = [ + ranges = [ IPv6Range(self._client, r["range"]) for r in result["ipv6"]["global"] ] @@ -481,7 +481,7 @@ def ips(self): "ipv6": { "slaac": slaac, "link_local": link_local, - "pools": pools, + "ranges": ranges, }, } ) diff --git a/test/objects/linode_test.py b/test/objects/linode_test.py index de3c5e20a..af470cdde 100644 --- a/test/objects/linode_test.py +++ b/test/objects/linode_test.py @@ -340,7 +340,7 @@ def test_ips(self): self.assertIsNotNone(ips.ipv4.reserved) self.assertIsNotNone(ips.ipv6.slaac) self.assertIsNotNone(ips.ipv6.link_local) - self.assertIsNotNone(ips.ipv6.pools) + self.assertIsNotNone(ips.ipv6.ranges) def test_initiate_migration(self): """ From 2142e4df027217923a8ce2beb8e1ecd2f7504407 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 30 May 2023 13:36:06 -0400 Subject: [PATCH 114/379] new: Move retries over to HTTPAdapter-based approach (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change moves the LinodeClient retry system over to an HTTPAdapter-based system. Additionally, this change introduces retires for `408` status responses. ## ✔️ How to Test ``` tox ``` --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- linode_api4/linode_client.py | 125 ++++++++++++++++-------- requirements-dev.txt | 3 +- test/linode_client_test.py | 181 ++++++++++++++++------------------- tox.ini | 1 + 4 files changed, 174 insertions(+), 136 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 1357ef887..a067a1755 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -2,11 +2,11 @@ import json import logging -import time from typing import BinaryIO, Tuple import pkg_resources import requests +from requests.adapters import HTTPAdapter, Retry from linode_api4.errors import ApiError, UnexpectedResponseError from linode_api4.groups import * @@ -22,6 +22,16 @@ logger = logging.getLogger(__name__) +class LinearRetry(Retry): + """ + Linear retry is a subclass of Retry that uses a linear backoff strategy. + This is necessary to maintain backwards compatibility with the old retry system. + """ + + def get_backoff_time(self): + return self.backoff_factor + + class LinodeClient: def __init__( self, @@ -29,7 +39,10 @@ def __init__( base_url="https://api.linode.com/v4", user_agent=None, page_size=None, - retry_rate_limit_interval=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, ): """ The main interface to the Linode API. @@ -51,26 +64,57 @@ def __init__( can be found in the API docs, but at time of writing are between 25 and 500. :type page_size: int - :param retry_rate_limit_interval: If given, 429 responses will be automatically - retried up to 5 times with the given interval, - in seconds, between attempts. - :type retry_rate_limit_interval: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: float + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. """ self.base_url = base_url self._add_user_agent = user_agent self.token = token - self.session = requests.Session() self.page_size = page_size - self.retry_rate_limit_interval = retry_rate_limit_interval + + retry_forcelist = [408, 429, 502] + + if retry_statuses is not None: + retry_forcelist.extend(retry_statuses) # make sure we got a sane backoff - if self.retry_rate_limit_interval is not None: - if not isinstance(self.retry_rate_limit_interval, int): - raise ValueError("retry_rate_limit_interval must be an int") - if self.retry_rate_limit_interval < 1: - raise ValueError( - "retry_rate_limit_interval must not be less than 1" - ) + if not isinstance(retry_rate_limit_interval, float): + raise ValueError("retry_rate_limit_interval must be a float") + + # Ensure the max retries value is valid + if not isinstance(retry_max, int): + raise ValueError("retry_max must be an int") + + self.retry = retry + self.retry_rate_limit_interval = retry_rate_limit_interval + self.retry_max = retry_max + self.retry_statuses = retry_statuses + + # Initialize the HTTP client session + self.session = requests.Session() + + self._retry_config = LinearRetry( + total=retry_max if retry else 0, + status_forcelist=retry_forcelist, + respect_retry_after_header=True, + backoff_factor=retry_rate_limit_interval, + raise_on_status=False, + ) + retry_adapter = HTTPAdapter(max_retries=self._retry_config) + + self.session.mount("http://", retry_adapter) + self.session.mount("https://", retry_adapter) #: Access methods related to Linodes - see :any:`LinodeGroup` for #: more information @@ -196,29 +240,11 @@ def _api_call( if data is not None: body = json.dumps(data) - # retry on 429 response - max_retries = 5 if self.retry_rate_limit_interval else 1 - for attempt in range(max_retries): - response = method(url, headers=headers, data=body) - - warning = response.headers.get("Warning", None) - if warning: - logger.warning( - "Received warning from server: {}".format(warning) - ) - - # if we were configured to retry 429s, and we got a 429, sleep briefly and then retry - if self.retry_rate_limit_interval and response.status_code == 429: - logger.warning( - "Received 429 response; waiting {} seconds and retrying request (attempt {}/{})".format( - self.retry_rate_limit_interval, - attempt, - max_retries, - ) - ) - time.sleep(self.retry_rate_limit_interval) - else: - break + response = method(url, headers=headers, data=body) + + warning = response.headers.get("Warning", None) + if warning: + logger.warning("Received warning from server: {}".format(warning)) if 399 < response.status_code < 600: j = None @@ -288,6 +314,29 @@ def put(self, *args, **kwargs): def delete(self, *args, **kwargs): return self._api_call(*args, method=self.session.delete, **kwargs) + def __setattr__(self, key, value): + # Allow for dynamic updating of the retry config + handlers = { + "retry_rate_limit_interval": lambda: setattr( + self._retry_config, "backoff_factor", value + ), + "retry": lambda: setattr( + self._retry_config, "total", self.retry_max if value else 0 + ), + "retry_max": lambda: setattr( + self._retry_config, "total", value if self.retry else 0 + ), + "retry_statuses": lambda: setattr( + self._retry_config, "status_forcelist", value + ), + } + + handler = handlers.get(key) + if hasattr(self, "_retry_config") and handler is not None: + handler() + + super().__setattr__(key, value) + def image_create(self, disk, label=None, description=None): """ .. note:: This method is an alias to maintain backwards compatibility. diff --git a/requirements-dev.txt b/requirements-dev.txt index a731835b8..d0597c403 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,4 +6,5 @@ mock>=5.0.0 tox>=4.4.0 Sphinx>=6.0.0 sphinx-autobuild>=2021.3.14 -sphinxcontrib-fulltoc>=1.2.0 \ No newline at end of file +sphinxcontrib-fulltoc>=1.2.0 +httpretty>=1.1.4 \ No newline at end of file diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 35378d482..4c0f9169c 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -1,7 +1,9 @@ from datetime import datetime from test.base import ClientBaseCase from unittest import TestCase -from unittest.mock import MagicMock + +import httpretty +import pytest from linode_api4 import ApiError, LinodeClient, LongviewSubscription from linode_api4.objects.linode import Instance @@ -1066,129 +1068,114 @@ def test_ipv6_ranges(self): class LinodeClientRateLimitRetryTest(TestCase): """ - Tests for rate limiting errors. + Tests for retrying on intermittent errors. .. warning:: This test class _does not_ follow normal testing conventions for this project, as requests are not automatically mocked. Only add tests to this class if they - pertain to the 429 retry logic, and make sure you mock the requests calls yourself + pertain to the retry logic, and make sure you mock the requests calls yourself (or else they will make real requests and those won't work). """ - def setUp(self): - self.client = LinodeClient( - "testing", base_url="/", retry_rate_limit_interval=1 - ) + def get_retry_client(self): + client = LinodeClient("testing", base_url="https://localhost") # sidestep the validation to do immediate retries so tests aren't slow - self.client.retry_rate_limit_interval = 0.1 - - def _get_mock_response(self, response_code): - """ - Helper function to return a mock response - """ - ret = MagicMock() - ret.status_code = response_code - ret.json.return_value = {} - - return ret + client.retry_rate_limit_interval = 0.1 + return client - def test_retry_429s(self): + @httpretty.activate + def test_retry_statuses(self): """ - Tests that 429 responses are automatically retried + Tests that retries work as expected on 408 and 429 responses. """ - called = 0 - def test_method(*args, **kwargs): - nonlocal called - called += 1 - if called < 2: - return self._get_mock_response(429) - return self._get_mock_response(200) + httpretty.register_uri( + httpretty.GET, + "https://localhost/test", + responses=[ + httpretty.Response( + body="{}", + status=408, + ), + httpretty.Response( + body="{}", + status=429, + ), + httpretty.Response( + body="{}", + status=200, + ), + ], + ) - response = self.client._api_call("/test", method=test_method) + self.get_retry_client().get("/test") - # it retried once, got the empty object - assert called == 2 - assert response == {}, response + assert len(httpretty.latest_requests()) == 3 - def test_retry_max_attempts(self): + @httpretty.activate + def test_retry_max(self): """ - Tests that a request will fail after 5 429 responses in a row + Tests that retries work as expected on 408 and 429 responses. """ - called = 0 - - def test_method(*args, **kwargs): - nonlocal called - called += 1 - return self._get_mock_response(429) - - try: - response = self.client._api_call("/test", method=test_method) - assert False, "Unexpectedly did not raise ApiError!" - except ApiError as e: - assert e.status == 429 - # it tried 5 times - assert called == 5 - - def test_api_error_with_retry(self): - """ - Tests that a 300+ response still raises an ApiError even if retries are - enabled - """ - called = 0 + httpretty.register_uri( + httpretty.GET, + "https://localhost/test", + responses=[ + httpretty.Response( + body="{}", + status=408, + ), + httpretty.Response( + body="{}", + status=429, + ), + httpretty.Response( + body="{}", + status=429, + ), + ], + ) - def test_method(*args, **kwargs): - nonlocal called - called += 1 - return self._get_mock_response(400) + client = self.get_retry_client() + client.retry_max = 2 try: - response = self.client._api_call("/test", method=test_method) - assert False, "Unexpectedly did not raise ApiError!" - except ApiError as e: - assert e.status == 400 + client.get("/test") + except ApiError as err: + assert err.status == 429 + else: + raise RuntimeError( + "Expected retry error after exceeding max retries" + ) - # it tried 5 times - assert called == 1 + assert len(httpretty.latest_requests()) == 3 - def test_api_error_on_retry(self): + @httpretty.activate + def test_retry_disable(self): """ - Tests that we'll stop retrying and raise immediately if we get a 300+ - response after a 429 + Tests that retries can be disabled. """ - called = 0 - def test_method(*args, **kwargs): - nonlocal called - called += 1 - if called < 2: - return self._get_mock_response(429) - return self._get_mock_response(400) + httpretty.register_uri( + httpretty.GET, + "https://localhost/test", + responses=[ + httpretty.Response( + body="{}", + status=408, + ), + ], + ) + + client = self.get_retry_client() + client.retry = False try: - response = self.client._api_call("/test", method=test_method) - assert False, "Unexpectedly did not raise ApiError!" + client.get("/test") except ApiError as e: - assert e.status == 400 - - # it tried 5 times - assert called == 2 - - def test_works_first_time(self): - """ - Tests that the response is handled correctly if we got a 200 on the first - try - """ - called = 0 - - def test_method(*args, **kwargs): - nonlocal called - called += 1 - return self._get_mock_response(200) - - response = self.client._api_call("/test", method=test_method) + assert e.status == 408 + else: + raise RuntimeError("Expected 408 error to be raised") - # it tried 5 times - assert called == 1 - assert response == {} + assert len(httpretty.latest_requests()) == 1 diff --git a/tox.ini b/tox.ini index b419801a5..0b51a2837 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ deps = coverage mock pylint + httpretty commands = python setup.py install coverage run --source linode_api4 -m pytest From 68d7c74013e34a9a8660214c4723bdc6117e30d5 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 1 Jun 2023 17:11:21 -0400 Subject: [PATCH 115/379] fix: Resolve issues with retry system implementation (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change resolves a few issues with the new HTTPAdapter-based retry system: - `retry_rate_limit_interval` does no longer explicitly require a float. - Retry statuses are now respected on update. - Retries are now enabled on `POST` requests. ## ✔️ How to Test ``` pytest test ``` --- linode_api4/linode_client.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index a067a1755..85ffa8718 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -69,7 +69,7 @@ def __init__( :type retry: bool :param retry_rate_limit_interval: The amount of time to wait between HTTP request retries. - :type retry_rate_limit_interval: float + :type retry_rate_limit_interval: Union[float, int] :param retry_max: The number of request retries that should be attempted before raising an API error. :type retry_max: int @@ -88,28 +88,27 @@ def __init__( if retry_statuses is not None: retry_forcelist.extend(retry_statuses) - # make sure we got a sane backoff - if not isinstance(retry_rate_limit_interval, float): - raise ValueError("retry_rate_limit_interval must be a float") - # Ensure the max retries value is valid if not isinstance(retry_max, int): raise ValueError("retry_max must be an int") self.retry = retry - self.retry_rate_limit_interval = retry_rate_limit_interval + self.retry_rate_limit_interval = float(retry_rate_limit_interval) self.retry_max = retry_max - self.retry_statuses = retry_statuses + self.retry_statuses = retry_forcelist # Initialize the HTTP client session self.session = requests.Session() self._retry_config = LinearRetry( total=retry_max if retry else 0, - status_forcelist=retry_forcelist, + status_forcelist=self.retry_statuses, respect_retry_after_header=True, - backoff_factor=retry_rate_limit_interval, + backoff_factor=self.retry_rate_limit_interval, raise_on_status=False, + # By default, POST is not an allowed method. + # We should explicitly include it. + allowed_methods={"DELETE", "GET", "POST", "PUT"}, ) retry_adapter = HTTPAdapter(max_retries=self._retry_config) From 66df89e73711da51ab9f73ccf4672c5c18cc0b80 Mon Sep 17 00:00:00 2001 From: ykim-1 <126618609+ykim-1@users.noreply.github.com> Date: Tue, 6 Jun 2023 09:15:48 -0700 Subject: [PATCH 116/379] Add integration tests (#240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Adding integration tests - more details at:https://jira.linode.com/browse/TPT-1831 ## ✔️ How to Test **What are the steps to reproduce the issue or verify the changes?** **How do I run the relevant unit/integration tests?** ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- .github/workflows/e2e-test-pr-command.yml | 19 + .github/workflows/e2e-test-pr.yml | 81 ++++ Makefile | 6 + requirements-dev.txt | 1 + requirements.txt | 2 +- test/integration/__init__.py | 0 test/integration/conftest.py | 218 +++++++++ test/integration/helpers.py | 115 +++++ test/integration/linode_client/__init__.py | 0 .../linode_client/test_linode_client.py | 406 ++++++++++++++++ test/integration/models/__init__.py | 0 test/integration/models/test_account.py | 99 ++++ test/integration/models/test_database.py | 445 ++++++++++++++++++ test/integration/models/test_domain.py | 60 +++ test/integration/models/test_firewall.py | 82 ++++ test/integration/models/test_image.py | 50 ++ test/integration/models/test_linode.py | 431 +++++++++++++++++ test/integration/models/test_lke.py | 137 ++++++ test/integration/models/test_longview.py | 45 ++ test/integration/models/test_networking.py | 12 + test/integration/models/test_nodebalancer.py | 119 +++++ test/integration/models/test_tag.py | 21 + test/integration/models/test_volume.py | 104 ++++ tox.ini | 2 +- 24 files changed, 2453 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/e2e-test-pr-command.yml create mode 100644 .github/workflows/e2e-test-pr.yml create mode 100644 test/integration/__init__.py create mode 100644 test/integration/conftest.py create mode 100644 test/integration/helpers.py create mode 100644 test/integration/linode_client/__init__.py create mode 100644 test/integration/linode_client/test_linode_client.py create mode 100644 test/integration/models/__init__.py create mode 100644 test/integration/models/test_account.py create mode 100644 test/integration/models/test_database.py create mode 100644 test/integration/models/test_domain.py create mode 100644 test/integration/models/test_firewall.py create mode 100644 test/integration/models/test_image.py create mode 100644 test/integration/models/test_linode.py create mode 100644 test/integration/models/test_lke.py create mode 100644 test/integration/models/test_longview.py create mode 100644 test/integration/models/test_networking.py create mode 100644 test/integration/models/test_nodebalancer.py create mode 100644 test/integration/models/test_tag.py create mode 100644 test/integration/models/test_volume.py diff --git a/.github/workflows/e2e-test-pr-command.yml b/.github/workflows/e2e-test-pr-command.yml new file mode 100644 index 000000000..3b52a695b --- /dev/null +++ b/.github/workflows/e2e-test-pr-command.yml @@ -0,0 +1,19 @@ +name: AccTest Command + +on: + issue_comment: + types: [created] + +jobs: + acctest-command: + runs-on: ubuntu-latest + if: ${{ github.event.issue.pull_request }} + steps: + - name: Slash Command Dispatch + uses: peter-evans/slash-command-dispatch@v1.2.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + issue-type: pull-request + commands: acctest + named-args: true + permission: write diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml new file mode 100644 index 000000000..020874a66 --- /dev/null +++ b/.github/workflows/e2e-test-pr.yml @@ -0,0 +1,81 @@ +on: + pull_request: + repository_dispatch: + types: [acctest-command] + +name: PR E2E Tests + +jobs: + # Maintainer has commented /acctest on a pull request + integration-fork-ubuntu: + runs-on: ubuntu-latest + if: + github.event_name == 'repository_dispatch' && + github.event.client_payload.slash_command.sha != '' && + github.event.client_payload.pull_request.head.sha == github.event.client_payload.slash_command.sha + + steps: + - uses: actions-ecosystem/action-regex-match@v2 + id: validate-tests + with: + text: ${{ github.event.client_payload.slash_command.tests }} + regex: '[^a-z0-9-:.\/_]' # Tests validation + flags: gi + + # Check out merge commit + - name: Checkout PR + uses: actions/checkout@v3 + with: + ref: ${{ github.event.client_payload.slash_command.sha }} + + - name: Update system packages + run: sudo apt-get update -y + + - name: Install system deps + run: sudo apt-get install -y build-essential + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + + - name: Install Python SDK + run: make install + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - run: make testint + if: ${{ steps.validate-tests.outputs.match == '' }} + env: + LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} + + - uses: actions/github-script@v5 + id: update-check-run + if: ${{ always() }} + env: + number: ${{ github.event.client_payload.pull_request.number }} + job: ${{ github.job }} + conclusion: ${{ job.status }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, + pull_number: process.env.number + }); + const ref = pull.head.sha; + const { data: checks } = await github.rest.checks.listForRef({ + ...context.repo, + ref + }); + const check = checks.check_runs.filter(c => c.name === process.env.job); + const { data: result } = await github.rest.checks.update({ + ...context.repo, + check_run_id: check[0].id, + status: 'completed', + conclusion: process.env.conclusion + }); + return result; \ No newline at end of file diff --git a/Makefile b/Makefile index e51e574bc..cf6c2431d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ PYTHON ?= python3 +INTEGRATION_TEST_PATH := + @PHONEY: clean clean: mkdir -p dist @@ -45,3 +47,7 @@ lint: autoflake --check linode_api4 test black --check --verbose linode_api4 test pylint linode_api4 + +@PHONEY: testint +testint: + python3 -m pytest test/integration/ diff --git a/requirements-dev.txt b/requirements-dev.txt index d0597c403..5e2d3165f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,4 +7,5 @@ tox>=4.4.0 Sphinx>=6.0.0 sphinx-autobuild>=2021.3.14 sphinxcontrib-fulltoc>=1.2.0 +pytest>=7.3.1 httpretty>=1.1.4 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9fe34db3d..3d78232b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ httplib2 enum34 -requests \ No newline at end of file +requests diff --git a/test/integration/__init__.py b/test/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/conftest.py b/test/integration/conftest.py new file mode 100644 index 000000000..b3fa15fb2 --- /dev/null +++ b/test/integration/conftest.py @@ -0,0 +1,218 @@ +import os +import time + +import pytest + +from linode_api4.linode_client import LinodeClient, LongviewSubscription + +ENV_TOKEN_NAME = "LINODE_TOKEN" +RUN_LONG_TESTS = "RUN_LONG_TESTS" + + +def get_token(): + return os.environ.get(ENV_TOKEN_NAME, None) + + +def run_long_tests(): + return os.environ.get(RUN_LONG_TESTS, None) + + +@pytest.fixture(scope="session") +def create_linode(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture +def create_linode_for_pass_reset(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + yield linode_instance, password + + linode_instance.delete() + + +@pytest.fixture(scope="session") +def ssh_key_gen(): + output = os.popen("ssh-keygen -q -t rsa -f ./sdk-sshkey -q -N ''") + + time.sleep(1) + + pub_file = open("./sdk-sshkey.pub", "r") + pub_key = pub_file.read().rstrip() + + priv_file = open("./sdk-sshkey", "r") + priv_key = priv_file.read().rstrip() + + yield pub_key, priv_key + + os.popen("rm ./sdk-sshkey*") + + +@pytest.fixture(scope="session") +def get_client(): + token = get_token() + client = LinodeClient(token) + return client + + +@pytest.fixture +def set_account_settings(get_client): + client = get_client + account_settings = client.account.settings() + account_settings._populated = True + account_settings.network_helper = True + + account_settings.save() + + +@pytest.fixture(scope="session") +def create_domain(get_client): + client = get_client + + timestamp = str(int(time.time())) + domain_addr = timestamp + "-example.com" + soa_email = "pathiel-test123@linode.com" + + domain = client.domain_create( + domain=domain_addr, soa_email=soa_email, tags=["test-tag"] + ) + + # Create a SRV record + domain.record_create( + "SRV", + target="rc_test", + priority=10, + weight=5, + port=80, + service="service_test", + ) + + yield domain + + domain.delete() + + +@pytest.fixture(scope="session") +def create_volume(get_client): + client = get_client + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + volume = client.volume_create(label=label, region="ap-west") + + yield volume + + volume.delete() + + +@pytest.fixture +def create_tag(get_client): + client = get_client + + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + tag = client.tag_create(label=label) + + yield tag + + tag.delete() + + +@pytest.fixture +def create_nodebalancer(get_client): + client = get_client + + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + nodebalancer = client.nodebalancer_create(region="us-east", label=label) + + yield nodebalancer + + nodebalancer.delete() + + +@pytest.fixture +def create_longview_client(get_client): + client = get_client + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + longview_client = client.longview.client_create(label=label) + + yield longview_client + + longview_client.delete() + + +@pytest.fixture +def upload_sshkey(get_client, ssh_key_gen): + pub_key = ssh_key_gen[0] + client = get_client + key = client.profile.ssh_key_upload(pub_key, "IntTestSDK-sshkey") + + yield key + + key.delete() + + +@pytest.fixture +def create_ssh_keys_object_storage(get_client): + client = get_client + label = "TestSDK-obj-storage-key" + key = client.object_storage.keys_create(label) + + yield key + + key.delete() + + +@pytest.fixture(scope="session") +def create_firewall(get_client): + client = get_client + rules = { + "outbound": [], + "outbound_policy": "DROP", + "inbound": [], + "inbound_policy": "ACCEPT", + } + + firewall = client.networking.firewall_create( + "test-firewall", rules=rules, status="enabled" + ) + + yield firewall + + firewall.delete() + + +@pytest.fixture +def create_oauth_client(get_client): + client = get_client + oauth_client = client.account.oauth_client_create( + "test-oauth-client", "https://localhost/oauth/callback" + ) + + yield oauth_client + + oauth_client.delete() diff --git a/test/integration/helpers.py b/test/integration/helpers.py new file mode 100644 index 000000000..eee46f385 --- /dev/null +++ b/test/integration/helpers.py @@ -0,0 +1,115 @@ +import random +import time +from typing import Callable + +from linode_api4 import PaginatedList +from linode_api4.errors import ApiError +from linode_api4.linode_client import LinodeClient + + +def get_test_label(): + unique_timestamp = str(int(time.time()) + random.randint(0, 1000)) + label = "IntTestSDK_" + unique_timestamp + return label + + +def delete_instance_with_test_kw(paginated_list: PaginatedList): + for i in paginated_list: + try: + if hasattr(i, "label"): + label = getattr(i, "label") + if "IntTestSDK" in str(label): + i.delete() + elif "lke" in str(label): + iso_created_date = getattr(i, "created") + created_time = int( + time.mktime(iso_created_date.timetuple()) + ) + timestamp = int(time.time()) + if (timestamp - created_time) < 86400: + i.delete() + elif hasattr(i, "domain"): + domain = getattr(i, "domain") + if "IntTestSDK" in domain: + i.delete() + except AttributeError as e: + if "IntTestSDK" in str(i.__dict__): + i.delete() + + +def delete_all_test_instances(client: LinodeClient): + tags = client.tags() + linodes = client.linode.instances() + images = client.images() + volumes = client.volumes() + nodebalancers = client.nodebalancers() + domains = client.domains() + longview_clients = client.longview.clients() + clusters = client.lke.clusters() + firewalls = client.networking.firewalls() + + delete_instance_with_test_kw(tags) + delete_instance_with_test_kw(linodes) + delete_instance_with_test_kw(images) + delete_instance_with_test_kw(volumes) + delete_instance_with_test_kw(nodebalancers) + delete_instance_with_test_kw(domains) + delete_instance_with_test_kw(longview_clients) + delete_instance_with_test_kw(clusters) + delete_instance_with_test_kw(firewalls) + + +def wait_for_condition( + interval: int, timeout: int, condition: Callable, *args +) -> object: + start_time = time.time() + while True: + if condition(*args): + break + + if time.time() - start_time > timeout: + raise TimeoutError("Wait for condition timeout error") + + time.sleep(interval) + + +# Retry function to help in case of requests sending too quickly before instance is ready +def retry_sending_request(retries: int, condition: Callable, *args) -> object: + curr_t = 0 + while curr_t < retries: + try: + curr_t += 1 + res = condition(*args) + return res + except ApiError: + if curr_t >= retries: + raise ApiError + time.sleep(5) + + +def send_request_when_resource_available( + timeout: int, func: Callable, *args +) -> object: + start_time = time.time() + + while True: + try: + res = func(*args) + return res + except ApiError as e: + if ( + e.status == 400 + or e.status == 500 + or "Please try again later" in str(e.__dict__) + ): + if time.time() - start_time > timeout: + raise TimeoutError( + "Timeout Error: resource is not available in" + + timeout + + "seconds" + ) + time.sleep(10) + else: + raise e + + return res diff --git a/test/integration/linode_client/__init__.py b/test/integration/linode_client/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py new file mode 100644 index 000000000..7549ae89a --- /dev/null +++ b/test/integration/linode_client/test_linode_client.py @@ -0,0 +1,406 @@ +import re +import time +from test.integration.helpers import get_test_label + +import pytest + +from linode_api4 import ApiError, LinodeClient +from linode_api4.objects import ObjectStorageKeys + + +@pytest.fixture(scope="session", autouse=True) +def setup_client_and_linode(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + yield client, linode_instance + + linode_instance.delete() + + +def test_get_account(setup_client_and_linode): + client = setup_client_and_linode[0] + account = client.account() + + assert re.search("^$|[a-zA-Z]+", account.first_name) + assert re.search("^$|[a-zA-Z]+", account.last_name) + assert re.search( + "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", account.email + ) + assert re.search( + "^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", account.phone + ) + assert re.search("^$|[a-zA-Z0-9]+", account.address_1) + assert re.search("^$|[a-zA-Z0-9]+", account.address_2) + assert re.search("^$|[a-zA-Z]+", account.city) + assert re.search("^$|[a-zA-Z]+", account.state) + assert re.search("^$|[a-zA-Z]+", account.country) + assert re.search("^$|[a-zA-Z0-9]+", account.zip) + if account.tax_id: + assert re.search("^$|[0-9]+", account.tax_id) + + +def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): + client = setup_client_and_linode[0] + + timestamp = str(int(time.time())) + domain_addr = timestamp + "example.com" + try: + domain = client.domain_create(domain=domain_addr) + except ApiError as e: + assert e.status == 400 + + +def test_get_domains(get_client, create_domain): + client = get_client + domain = create_domain + domain_dict = client.domains() + + dom_list = [i.domain for i in domain_dict] + + assert domain.domain in dom_list + + +def test_image_create(setup_client_and_linode): + client = setup_client_and_linode[0] + linode = setup_client_and_linode[1] + + label = get_test_label() + description = "Test description" + disk_id = linode.disks.first().id + + image = client.image_create( + disk=disk_id, label=label, description=description + ) + + assert image.label == label + assert image.description == description + + +def test_fails_to_create_image_with_non_existing_disk_id( + setup_client_and_linode, +): + client = setup_client_and_linode[0] + + label = get_test_label() + description = "Test description" + disk_id = 111111 + + try: + image_page = client.image_create( + disk=disk_id, label=label, description=description + ) + except ApiError as e: + assert "Not found" in str(e.json) + assert e.status == 404 + + +def test_fails_to_delete_predefined_images(setup_client_and_linode): + client = setup_client_and_linode[0] + + images = client.images() + + try: + # new images go on top of the list thus choose last image + images.last().delete() + except ApiError as e: + assert "Unauthorized" in str(e.json) + assert e.status == 403 + + +def test_get_volume(get_client, create_volume): + client = get_client + label = create_volume.label + + volume_dict = client.volumes() + + volume_label_list = [i.label for i in volume_dict] + + assert label in volume_label_list + + +def test_get_tag(get_client, create_tag): + client = get_client + label = create_tag.label + + tags = client.tags() + + tag_label_list = [i.label for i in tags] + + assert label in tag_label_list + + +def test_create_tag_with_id( + setup_client_and_linode, create_nodebalancer, create_domain, create_volume +): + client = setup_client_and_linode[0] + linode = setup_client_and_linode[1] + nodebalancer = create_nodebalancer + domain = create_domain + volume = create_volume + + label = get_test_label() + + tag = client.tag_create( + label=label, + instances=[linode.id, linode], + nodebalancers=[nodebalancer.id, nodebalancer], + domains=[domain.id, domain], + volumes=[volume.id, volume], + ) + + # Get tags after creation + tags = client.tags() + + tag_label_list = [i.label for i in tags] + + tag.delete() + + assert label in tag_label_list + + +def test_create_tag_with_entities( + setup_client_and_linode, create_nodebalancer, create_domain, create_volume +): + client = setup_client_and_linode[0] + linode = setup_client_and_linode[1] + nodebalancer = create_nodebalancer + domain = create_domain + volume = create_volume + + label = get_test_label() + + tag = client.tag_create( + label, entities=[linode, domain, nodebalancer, volume] + ) + + # Get tags after creation + tags = client.tags() + + tag_label_list = [i.label for i in tags] + + tag.delete() + + assert label in tag_label_list + + +# AccountGroupTests +def test_get_account_settings(get_client): + client = get_client + account_settings = client.account.settings() + + assert account_settings._populated == True + assert re.search( + "'network_helper':True|False", str(account_settings._raw_json) + ) + + +# TODO: Account invoice and payment test cases need to be added + + +# LinodeGroupTests +def test_create_linode_instance_without_image(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode_instance = client.linode.instance_create( + "g5-standard-4", chosen_region, label=label + ) + + assert linode_instance.label == label + assert linode_instance.image is None + + res = linode_instance.delete() + + assert res + + +def test_create_linode_instance_with_image(setup_client_and_linode): + linode = setup_client_and_linode[1] + + assert re.search("linode/debian9", str(linode.image)) + + +# LongviewGroupTests +def test_get_longview_clients(get_client, create_longview_client): + client = get_client + + longview_client = client.longview.clients() + + client_labels = [i.label for i in longview_client] + + assert create_longview_client.label in client_labels + + +def test_client_create_with_label(get_client): + client = get_client + label = get_test_label() + longview_client = client.longview.client_create(label=label) + + assert label == longview_client.label + + time.sleep(5) + + res = longview_client.delete() + + assert res + + +# TODO: Subscription related test cases need to be added, currently returns a 404 +# def test_get_subscriptions(): + + +# LKEGroupTest + + +def test_kube_version(get_client): + client = get_client + lke_version = client.lke.versions() + + assert re.search("[0-9].[0-9]+", lke_version.first().id) + + +def test_cluster_create_with_api_objects(get_client): + client = get_client + node_type = client.linode.types()[1] # g6-standard-1 + version = client.lke.versions()[0] + region = client.regions().first() + node_pools = client.lke.node_pool(node_type, 3) + label = get_test_label() + "-cluster" + + cluster = client.lke.cluster_create(region, label, node_pools, version) + + assert cluster.region.id == region.id + assert cluster.k8s_version.id == version.id + + res = cluster.delete() + + assert res + + +def test_fails_to_create_cluster_with_invalid_version(get_client): + invalid_version = "a.12" + client = get_client + + try: + cluster = client.lke.cluster_create( + "ap-west", + "example-cluster", + {"type": "g6-standard-1", "count": 3}, + invalid_version, + ) + except ApiError as e: + assert "not valid" in str(e.json) + assert e.status == 400 + + +# ProfileGroupTest + + +def test_get_sshkeys(get_client, upload_sshkey): + client = get_client + + ssh_keys = client.profile.ssh_keys() + + ssh_labels = [i.label for i in ssh_keys] + + assert upload_sshkey.label in ssh_labels + + +def test_ssh_key_create(upload_sshkey, ssh_key_gen): + pub_key = ssh_key_gen[0] + key = upload_sshkey + + assert pub_key == key._raw_json["ssh_key"] + + +# ObjectStorageGroupTests + + +def test_get_object_storage_clusters(get_client): + client = get_client + + clusters = client.object_storage.clusters() + + assert "us-east" in clusters[0].id + assert "us-east" in clusters[0].region.id + + +def test_get_keys(get_client, create_ssh_keys_object_storage): + client = get_client + key = create_ssh_keys_object_storage + + keys = client.object_storage.keys() + key_labels = [i.label for i in keys] + + assert key.label in key_labels + + +def test_keys_create(get_client, create_ssh_keys_object_storage): + key = create_ssh_keys_object_storage + + assert type(key) == type(ObjectStorageKeys(client=get_client, id="123")) + + +# NetworkingGroupTests + +# TODO:: creating vlans +# def test_get_vlans(): + + +@pytest.fixture +def create_firewall_with_inbound_outbound_rules(get_client): + client = get_client + label = get_test_label() + "-firewall" + rules = { + "outbound": [ + { + "ports": "22", + "protocol": "TCP", + "addresses": {"ipv4": ["198.0.0.2/32"]}, + "action": "ACCEPT", + "label": "accept-inbound-SSH", + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "ports": "22", + "protocol": "TCP", + "addresses": {"ipv4": ["198.0.0.2/32"]}, + "action": "ACCEPT", + "label": "accept-inbound-SSH", + } + ], + "inbound_policy": "ACCEPT", + } + + firewall = client.networking.firewall_create( + label, rules=rules, status="enabled" + ) + + yield firewall + + firewall.delete() + + +def test_get_firewalls_with_inbound_outbound_rules( + get_client, create_firewall_with_inbound_outbound_rules +): + client = get_client + firewalls = client.networking.firewalls() + firewall = create_firewall_with_inbound_outbound_rules + + firewall_labels = [i.label for i in firewalls] + + assert firewall.label in firewall_labels + assert firewall.rules.inbound_policy == "ACCEPT" + assert firewall.rules.outbound_policy == "DROP" diff --git a/test/integration/models/__init__.py b/test/integration/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration/models/test_account.py b/test/integration/models/test_account.py new file mode 100644 index 000000000..46542545d --- /dev/null +++ b/test/integration/models/test_account.py @@ -0,0 +1,99 @@ +import time +from test.integration.helpers import get_test_label + +from linode_api4.objects import ( + Account, + AccountSettings, + Event, + Login, + OAuthClient, + User, +) + + +def test_get_account(get_client): + client = get_client + account = client.account() + account_id = account.id + account_get = client.load(Account, account_id) + + assert account_get.first_name == account.first_name + assert account_get.last_name == account.last_name + assert account_get.email == account.email + assert account_get.phone == account.phone + assert account_get.address_1 == account.address_1 + assert account_get.address_2 == account.address_2 + assert account_get.city == account.city + assert account_get.state == account.state + assert account_get.country == account.country + assert account_get.zip == account.zip + assert account_get.tax_id == account.tax_id + + +def test_get_login(get_client): + client = get_client + login = client.load(Login(client, "", {}), "") + + updated_time = int(time.mktime(getattr(login, "_last_updated").timetuple())) + + login_updated = int(time.time()) - updated_time + + assert "username" in str(login._raw_json) + assert "ip" in str(login._raw_json) + assert "datetime" in str(login._raw_json) + assert "status" in str(login._raw_json) + assert login_updated < 15 + + +def test_get_account_settings(get_client): + client = get_client + account_settings = client.load(AccountSettings(client, ""), "") + + assert "managed" in str(account_settings._raw_json) + assert "network_helper" in str(account_settings._raw_json) + assert "longview_subscription" in str(account_settings._raw_json) + assert "backups_enabled" in str(account_settings._raw_json) + assert "object_storage" in str(account_settings._raw_json) + + +def test_latest_get_event(get_client): + client = get_client + + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + events = client.load(Event, "") + + latest_event = events._raw_json.get("data")[0] + + linode.delete() + + assert label in latest_event["entity"]["label"] + + +def test_get_oathclient(get_client, create_oauth_client): + client = get_client + + oauth_client = client.load(OAuthClient, create_oauth_client.id) + + assert "test-oauth-client" == oauth_client.label + assert "https://localhost/oauth/callback" == oauth_client.redirect_uri + + +def test_get_user(get_client): + client = get_client + + events = client.load(Event, "") + + username = events._raw_json.get("data")[0]["username"] + + user = client.load(User, username) + + assert username == user.username + assert "email" in user._raw_json + assert "email" in user._raw_json diff --git a/test/integration/models/test_database.py b/test/integration/models/test_database.py new file mode 100644 index 000000000..974c5c923 --- /dev/null +++ b/test/integration/models/test_database.py @@ -0,0 +1,445 @@ +import re +import time +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + +import pytest + +from linode_api4 import LinodeClient +from linode_api4.objects import MySQLDatabase, PostgreSQLDatabase + + +# Test Helpers +def get_db_engine_id(client: LinodeClient, engine: str): + engines = client.database.engines() + engine_id = "" + for e in engines: + if e.engine == engine: + engine_id = e.id + + return str(engine_id) + + +def get_sql_db_status(client: LinodeClient, db_id, status: str): + db = client.load(MySQLDatabase, db_id) + return db.status == status + + +def get_postgres_db_status(client: LinodeClient, db_id, status: str): + db = client.load(PostgreSQLDatabase, db_id) + return db.status == status + + +@pytest.fixture(scope="session") +def test_create_sql_db(get_client): + client = get_client + label = get_test_label() + "-sqldb" + region = "us-east" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +@pytest.fixture(scope="session") +def test_create_postgres_db(get_client): + client = get_client + label = get_test_label() + "-postgresqldb" + region = "us-east" + engine_id = get_db_engine_id(client, "postgresql") + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +# ------- SQL DB Test cases ------- +def test_get_types(get_client): + client = get_client + types = client.database.types() + + assert (types[0].type_class, "nanode") + assert (types[0].id, "g6-nanode-1") + assert (types[0].engines.mongodb[0].price.monthly, 15) + + +def test_get_engines(get_client): + client = get_client + engines = client.database.engines() + + for e in engines: + assert e.engine in ["mysql", "postgresql"] + assert re.search("[0-9]+.[0-9]+", e.version) + assert e.id == e.engine + "/" + e.version + + +def test_database_instance(get_client, test_create_sql_db): + dbs = get_client.database.mysql_instances() + + assert str(test_create_sql_db.id) in str(dbs.lists) + + +# ------- POSTGRESQL DB Test cases ------- +def test_get_sql_db_instance(get_client, test_create_sql_db): + dbs = get_client.database.mysql_instances() + database = "" + for db in dbs: + if db.id == test_create_sql_db.id: + database = db + + assert str(test_create_sql_db.id) == str(database.id) + assert str(test_create_sql_db.label) == str(database.label) + assert database.cluster_size == 1 + assert database.engine == "mysql" + assert "-mysql-primary.servers.linodedb.net" in database.hosts.primary + + +def test_update_sql_db(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + + new_allow_list = ["192.168.0.1/32"] + label = get_test_label() + "updatedSQLDB" + + db.allow_list = new_allow_list + db.updates.day_of_week = 2 + db.label = label + + res = db.save() + + database = get_client.load(MySQLDatabase, test_create_sql_db.id) + + wait_for_condition( + 30, 300, get_sql_db_status, get_client, test_create_sql_db.id, "active" + ) + + assert res + assert database.allow_list == new_allow_list + assert database.label == label + assert database.updates.day_of_week == 2 + + +def test_create_sql_backup(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + label = "database_backup_test" + + wait_for_condition( + 30, 300, get_sql_db_status, get_client, test_create_sql_db.id, "active" + ) + + db.backup_create(label=label, target="secondary") + + wait_for_condition( + 10, + 300, + get_sql_db_status, + get_client, + test_create_sql_db.id, + "backing_up", + ) + + assert db.status == "backing_up" + + # list backup and most recently created one is first element of the array + wait_for_condition( + 30, 600, get_sql_db_status, get_client, test_create_sql_db.id, "active" + ) + + backup = db.backups[0] + + assert backup.label == label + assert backup.database_id == test_create_sql_db.id + + assert db.status == "active" + + backup.delete() + + +def test_sql_backup_restore(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + try: + backup = db.backups[0] + except IndexError as e: + pytest.skip( + "Skipping this test. Reason: Couldn't find db backup instance" + ) + + backup.restore() + + wait_for_condition( + 10, + 300, + get_sql_db_status, + get_client, + test_create_sql_db.id, + "restoring", + ) + + assert db.status == "restoring" + + wait_for_condition( + 30, 1000, get_sql_db_status, get_client, test_create_sql_db.id, "active" + ) + + assert db.status == "active" + + +def test_get_sql_ssl(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + + assert "ca_certificate" in str(db.ssl) + + +def test_sql_patch(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + + db.patch() + + wait_for_condition( + 10, + 300, + get_sql_db_status, + get_client, + test_create_sql_db.id, + "updating", + ) + + assert db.status == "updating" + + wait_for_condition( + 30, 1000, get_sql_db_status, get_client, test_create_sql_db.id, "active" + ) + + assert db.status == "active" + + +def test_get_sql_credentials(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + + assert db.credentials.username == "linroot" + assert db.credentials.password + + +def test_reset_sql_credentials(get_client, test_create_sql_db): + db = get_client.load(MySQLDatabase, test_create_sql_db.id) + + old_pass = str(db.credentials.password) + + print(old_pass) + db.credentials_reset() + + time.sleep(5) + + assert db.credentials.username == "linroot" + assert db.credentials.password != old_pass + + +# ------- POSTGRESQL DB Test cases ------- +def test_get_postgres_db_instance(get_client, test_create_postgres_db): + dbs = get_client.database.postgresql_instances() + + for db in dbs: + if db.id == test_create_postgres_db.id: + database = db + + assert str(test_create_postgres_db.id) == str(database.id) + assert str(test_create_postgres_db.label) == str(database.label) + assert database.cluster_size == 1 + assert database.engine == "postgresql" + assert "pgsql-primary.servers.linodedb.net" in database.hosts.primary + + +def test_update_postgres_db(get_client, test_create_postgres_db): + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + new_allow_list = ["192.168.0.1/32"] + label = get_test_label() + "updatedPostgresDB" + + db.allow_list = new_allow_list + db.updates.day_of_week = 2 + db.label = label + + res = db.save() + + database = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + wait_for_condition( + 30, + 1000, + get_postgres_db_status, + get_client, + test_create_postgres_db.id, + "active", + ) + + assert res + assert database.allow_list == new_allow_list + assert database.label == label + assert database.updates.day_of_week == 2 + + +def test_create_postgres_backup(get_client, test_create_postgres_db): + pytest.skip( + "Failing due to '400: The backup snapshot request failed, please contact support.'" + ) + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + label = "database_backup_test" + + wait_for_condition( + 30, + 1000, + get_postgres_db_status, + get_client, + test_create_postgres_db.id, + "active", + ) + + db.backup_create(label=label, target="secondary") + + # list backup and most recently created one is first element of the array + wait_for_condition( + 10, + 300, + get_sql_db_status, + get_client, + test_create_postgres_db.id, + "backing_up", + ) + + assert db.status == "backing_up" + + # list backup and most recently created one is first element of the array + wait_for_condition( + 30, + 600, + get_sql_db_status, + get_client, + test_create_postgres_db.id, + "active", + ) + + # list backup and most recently created one is first element of the array + backup = db.backups[0] + + assert backup.label == label + assert backup.database_id == test_create_postgres_db.id + + +def test_postgres_backup_restore(get_client, test_create_postgres_db): + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + try: + backup = db.backups[0] + except IndexError as e: + pytest.skip( + "Skipping this test. Reason: Couldn't find db backup instance" + ) + + backup.restore() + + wait_for_condition( + 30, + 1000, + get_postgres_db_status, + get_client, + test_create_postgres_db.id, + "restoring", + ) + + wait_for_condition( + 30, + 1000, + get_postgres_db_status, + get_client, + test_create_postgres_db.id, + "active", + ) + + assert db.status == "active" + + +def test_get_postgres_ssl(get_client, test_create_postgres_db): + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + assert "ca_certificate" in str(db.ssl) + + +def test_postgres_patch(get_client, test_create_postgres_db): + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + db.patch() + + wait_for_condition( + 10, + 300, + get_postgres_db_status, + get_client, + test_create_postgres_db.id, + "updating", + ) + + assert db.status == "updating" + + wait_for_condition( + 30, + 600, + get_postgres_db_status, + get_client, + test_create_postgres_db.id, + "active", + ) + + assert db.status == "active" + + +def test_get_postgres_credentials(get_client, test_create_postgres_db): + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + assert db.credentials.username == "linpostgres" + assert db.credentials.password + + +def test_reset_postgres_credentials(get_client, test_create_postgres_db): + db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + old_pass = str(db.credentials.password) + + db.credentials_reset() + + time.sleep(5) + + assert db.credentials.username == "linpostgres" + assert db.credentials.password != old_pass diff --git a/test/integration/models/test_domain.py b/test/integration/models/test_domain.py new file mode 100644 index 000000000..2185a53d1 --- /dev/null +++ b/test/integration/models/test_domain.py @@ -0,0 +1,60 @@ +import re +import time +from test.integration.helpers import wait_for_condition + +import pytest + +from linode_api4.objects import Domain, DomainRecord + + +def test_get_domain_record(get_client, create_domain): + dr = DomainRecord( + get_client, create_domain.records.first().id, create_domain.id + ) + + assert dr.id == create_domain.records.first().id + + +def test_save_null_values_excluded(get_client, create_domain): + domain = get_client.load(Domain, create_domain.id) + + domain.type = "master" + domain.master_ips = ["127.0.0.1"] + res = domain.save() + + assert res + + +def test_zone_file_view(get_client, create_domain): + domain = get_client.load(Domain, create_domain.id) + + def get_zone_file_view(): + res = domain.zone_file_view() + return res != [] + + wait_for_condition(10, 100, get_zone_file_view) + + assert domain.domain in str(domain.zone_file_view()) + assert re.search("ns[0-9].linode.com", str(domain.zone_file_view())) + + +def test_clone(get_client, create_domain): + domain = get_client.load(Domain, create_domain.id) + timestamp = str(int(time.time())) + dom = "example.clone-" + timestamp + "-IntTestSDK.org" + domain.clone(dom) + + ds = get_client.domains() + + time.sleep(1) + + domains = [i.domain for i in ds] + + assert dom in domains + + +def test_import(get_client, create_domain): + pytest.skip( + 'Currently failing with message: linode_api4.errors.ApiError: 400: An unknown error occured. Please open a ticket for further assistance. Command: domain_import(domain, "google.ca")' + ) + domain = get_client.load(Domain, create_domain.id) diff --git a/test/integration/models/test_firewall.py b/test/integration/models/test_firewall.py new file mode 100644 index 000000000..6f0543516 --- /dev/null +++ b/test/integration/models/test_firewall.py @@ -0,0 +1,82 @@ +import time + +import pytest + +from linode_api4.objects import Firewall, FirewallDevice + + +@pytest.fixture(scope="session") +def create_linode_fw(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = "linode_instance_fw_device" + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + yield linode_instance + + linode_instance.delete() + + +def test_get_firewall_rules(get_client, create_firewall): + firewall = get_client.load(Firewall, create_firewall.id) + rules = firewall.rules + + assert rules.inbound_policy in ["ACCEPT", "DROP"] + assert rules.outbound_policy in ["ACCEPT", "DROP"] + + +def test_update_firewall_rules(get_client, create_firewall): + firewall = get_client.load(Firewall, create_firewall.id) + new_rules = { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": ["0.0.0.0/0"], + "ipv6": ["ff00::/8"], + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP", + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + } + + firewall.update_rules(new_rules) + + time.sleep(1) + + firewall = get_client.load(Firewall, create_firewall.id) + + assert firewall.rules.inbound_policy == "ACCEPT" + assert firewall.rules.outbound_policy == "DROP" + + +def test_get_devices(get_client, create_linode_fw, create_firewall): + linode = create_linode_fw + + create_firewall.device_create(int(linode.id)) + + firewall = get_client.load(Firewall, create_firewall.id) + + assert len(firewall.devices) > 0 + + +def test_get_device(get_client, create_firewall, create_linode_fw): + firewall = create_firewall + + firewall_device = get_client.load( + FirewallDevice, firewall.devices.first().id, firewall.id + ) + + assert firewall_device.entity.label == "linode_instance_fw_device" + assert firewall_device.entity.type == "linode" + assert "/v4/linode/instances/" in firewall_device.entity.url diff --git a/test/integration/models/test_image.py b/test/integration/models/test_image.py new file mode 100644 index 000000000..6cd97d468 --- /dev/null +++ b/test/integration/models/test_image.py @@ -0,0 +1,50 @@ +from io import BytesIO +from test.integration.helpers import ( + delete_instance_with_test_kw, + get_test_label, +) + +import pytest + +from linode_api4.objects import Image + + +@pytest.fixture(scope="session") +def image_upload(get_client): + label = get_test_label() + "_image" + + get_client.image_create_upload( + label, "us-east", "integration test image upload" + ) + + image = get_client.images()[0] + + yield image + + image.delete() + images = get_client.images() + delete_instance_with_test_kw(images) + + +def test_get_image(get_client, image_upload): + image = get_client.load(Image, image_upload.id) + + assert image.label == image_upload.label + + +def test_image_create_upload(get_client): + test_image_content = ( + b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" + b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" + ) + + label = get_test_label() + "_image" + image = get_client.image_upload( + label, + "us-east", + BytesIO(test_image_content), + description="integration test image upload", + ) + + assert image.label == label + assert image.description == "integration test image upload" diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py new file mode 100644 index 000000000..a756ab944 --- /dev/null +++ b/test/integration/models/test_linode.py @@ -0,0 +1,431 @@ +import time +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) + +import pytest + +from linode_api4.errors import ApiError +from linode_api4.objects import Config, Disk, Image, Instance, Type + + +@pytest.fixture(scope="session") +def create_linode_with_volume_firewall(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + rules = { + "outbound": [], + "outbound_policy": "DROP", + "inbound": [], + "inbound_policy": "ACCEPT", + } + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", + chosen_region, + image="linode/debian9", + label=label + "_modlinode", + ) + + volume = client.volume_create( + label=label + "_volume", + region=linode_instance.region.id, + linode=linode_instance.id, + ) + + firewall = client.networking.firewall_create( + label=label + "_firewall", rules=rules, status="enabled" + ) + + firewall.device_create(int(linode_instance.id)) + + yield linode_instance + + firewall.delete() + + linode_instance.delete() + + volume.detach() + # wait for volume detach, can't currently get the attach/unattached status via SDK + time.sleep(30) + + volume.delete() + + +@pytest.fixture +def create_linode_for_long_running_tests(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", + chosen_region, + image="linode/debian9", + label=label + "_long_tests", + ) + + yield linode_instance + + linode_instance.delete() + + +# Test helper +def get_status(linode: Instance, status: str): + return linode.status == status + + +def test_get_linode(get_client, create_linode_with_volume_firewall): + linode = get_client.load(Instance, create_linode_with_volume_firewall.id) + + assert linode.label == create_linode_with_volume_firewall.label + assert linode.id == create_linode_with_volume_firewall.id + + +def test_linode_transfer(get_client, create_linode_with_volume_firewall): + linode = get_client.load(Instance, create_linode_with_volume_firewall.id) + + transfer = linode.transfer + + assert "used" in str(transfer) + assert "quota" in str(transfer) + assert "billable" in str(transfer) + + +def test_linode_rebuild(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + "_rebuild" + + linode, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + wait_for_condition(10, 100, get_status, linode, "running") + + retry_sending_request(3, linode.rebuild, "linode/debian9") + + wait_for_condition(10, 100, get_status, linode, "rebuilding") + + assert linode.status == "rebuilding" + assert linode.image.id == "linode/debian9" + + wait_for_condition(10, 300, get_status, linode, "running") + + assert linode.status == "running" + + linode.delete() + + +def test_linode_available_backups(create_linode): + linode = create_linode + + enable_backup = linode.enable_backups() + backups = linode.backups + + assert enable_backup + assert "enabled" in str(backups) + assert "available" in str(backups) + assert "schedule" in str(backups) + assert "last_successful" in str(backups) + + +def test_update_linode(create_linode): + linode = create_linode + new_label = get_test_label() + "_updated" + linode.label = new_label + linode.group = "new_group" + updated = linode.save() + + assert updated + assert linode.label == new_label + + +def test_delete_linode(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", + chosen_region, + image="linode/debian9", + label=label + "_linode", + ) + + linode_instance.delete() + + +def test_linode_reboot(create_linode): + linode = create_linode + + wait_for_condition(3, 100, get_status, linode, "running") + + retry_sending_request(3, linode.reboot) + + wait_for_condition(3, 100, get_status, linode, "rebooting") + assert linode.status == "rebooting" + + wait_for_condition(3, 100, get_status, linode, "running") + assert linode.status == "running" + + +def test_linode_shutdown(create_linode): + linode = create_linode + + wait_for_condition(10, 100, get_status, linode, "running") + + retry_sending_request(3, linode.shutdown) + + wait_for_condition(10, 100, get_status, linode, "offline") + + assert linode.status == "offline" + + +def test_linode_boot(create_linode): + linode = create_linode + + if linode.status != "offline": + retry_sending_request(3, linode.shutdown) + wait_for_condition(3, 100, get_status, linode, "offline") + retry_sending_request(3, linode.boot) + else: + retry_sending_request(3, linode.boot) + + wait_for_condition(10, 100, get_status, linode, "running") + + assert linode.status == "running" + + +def test_linode_resize(create_linode_for_long_running_tests): + linode = create_linode_for_long_running_tests + + wait_for_condition(10, 100, get_status, linode, "running") + + retry_sending_request(3, linode.resize, "g6-standard-6") + + wait_for_condition(10, 100, get_status, linode, "resizing") + + assert linode.status == "resizing" + + # Takes about 3-5 minute to resize, sometimes longer... + wait_for_condition(30, 600, get_status, linode, "running") + + assert linode.status == "running" + + +def test_linode_resize_with_class( + get_client, create_linode_for_long_running_tests +): + linode = create_linode_for_long_running_tests + ltype = Type(get_client, "g6-standard-6") + + wait_for_condition(10, 100, get_status, linode, "running") + + time.sleep(5) + res = linode.resize(new_type=ltype) + + assert res + + wait_for_condition(10, 300, get_status, linode, "resizing") + + assert linode.status == "resizing" + + # Takes about 3-5 minute to resize, sometimes longer... + wait_for_condition(30, 600, get_status, linode, "running") + + assert linode.status == "running" + + +def test_linode_boot_with_config(create_linode): + linode = create_linode + + wait_for_condition(10, 100, get_status, linode, "running") + retry_sending_request(3, linode.shutdown) + + wait_for_condition(30, 300, get_status, linode, "offline") + + config = linode.configs[0] + + retry_sending_request(3, linode.boot, config) + + wait_for_condition(10, 100, get_status, linode, "running") + + assert linode.status == "running" + + +def test_linode_firewalls(create_linode_with_volume_firewall): + linode = create_linode_with_volume_firewall + + firewalls = linode.firewalls() + + assert len(firewalls) > 0 + assert "TestSDK" in firewalls[0].label + + +def test_linode_volumes(create_linode_with_volume_firewall): + linode = create_linode_with_volume_firewall + + volumes = linode.volumes() + + assert len(volumes) > 0 + assert "TestSDK" in volumes[0].label + + +def test_linode_disk_duplicate(get_client, create_linode): + pytest.skip("Need to find out the space sizing when duplicating disks") + linode = create_linode + + disk = get_client.load(Disk, linode.disks[0].id, linode.id) + + try: + dup_disk = disk.duplicate() + assert dup_disk.linode_id == linode.id + except ApiError as e: + assert e.status == 400 + assert "Insufficient space" in str(e.json) + + +def test_linode_instance_password(create_linode_for_pass_reset): + pytest.skip("Failing due to mismatched request body") + linode = create_linode_for_pass_reset[0] + password = create_linode_for_pass_reset[1] + + wait_for_condition(10, 100, get_status, linode, "running") + + retry_sending_request(3, linode.shutdown) + + wait_for_condition(10, 200, get_status, linode, "offline") + + linode.reset_instance_root_password(root_password=password) + + linode.boot() + + wait_for_condition(10, 100, get_status, linode, "running") + + assert linode.status == "running" + + +def test_linode_ips(create_linode): + linode = create_linode + + ips = linode.ips + + assert ips.ipv4.public[0].address == linode.ipv4[0] + + +def test_linode_initate_migration(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + "_migration" + + linode, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + wait_for_condition(10, 100, get_status, linode, "running") + # Says it could take up to ~6 hrs for migration to fully complete + linode.initiate_migration(region="us-central") + + res = linode.delete() + + assert res + + +def test_linode_create_disk(create_linode): + pytest.skip( + "Pre-requisite for the test account need to comply with this test" + ) + linode = create_linode + disk, gen_pass = linode.disk_create() + + +def test_disk_resize(): + pytest.skip( + "Pre-requisite for the test account need to comply with this test" + ) + + +def test_config_update_interfaces(create_linode): + linode = create_linode + new_interfaces = [ + {"purpose": "public"}, + {"purpose": "vlan", "label": "cool-vlan"}, + ] + + config = linode.configs[0] + + config.interfaces = new_interfaces + + res = config.save() + + assert res + assert "cool-vlan" in str(config.interfaces) + + +def test_get_config(get_client, create_linode): + pytest.skip( + "Model get method: client.load(Config, 123, 123) does not work..." + ) + linode = create_linode + json = get_client.get( + "linode/instances/" + + str(linode.id) + + "/configs/" + + str(linode.configs[0].id) + ) + config = Config(get_client, linode.id, linode.configs[0].id, json=json) + + assert config.id == linode.configs[0].id + + +def test_get_linode_types(get_client): + types = get_client.linode.types() + + ids = [i.id for i in types] + + assert len(types) > 0 + assert "g6-nanode-1" in ids + + +def test_get_linode_type_by_id(get_client): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + + +def test_get_linode_type_gpu(): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + + +def test_save_linode_noforce(get_client, create_linode): + linode = create_linode + old_label = linode.label + linode.label = "updated_no_force_label" + linode.save(force=False) + + linode = get_client.load(Instance, linode.id) + + assert old_label != linode.label + + +def test_save_linode_force(get_client, create_linode): + linode = create_linode + old_label = linode.label + linode.label = "updated_force_label" + linode.save(force=False) + + linode = get_client.load(Instance, linode.id) + + assert old_label != linode.label diff --git a/test/integration/models/test_lke.py b/test/integration/models/test_lke.py new file mode 100644 index 000000000..094e9ae36 --- /dev/null +++ b/test/integration/models/test_lke.py @@ -0,0 +1,137 @@ +import re +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + +import pytest + +from linode_api4.errors import ApiError +from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode + + +@pytest.fixture(scope="session") +def create_lke_cluster(get_client): + node_type = get_client.linode.types()[1] # g6-standard-1 + version = get_client.lke.versions()[0] + region = get_client.regions().first() + node_pools = get_client.lke.node_pool(node_type, 3) + label = get_test_label() + "_cluster" + + cluster = get_client.lke.cluster_create(region, label, node_pools, version) + + yield cluster + + cluster.delete() + + +def get_cluster_status(cluster: LKECluster, status: str): + return cluster._raw_json["status"] == status + + +def get_node_status(cluster: LKECluster, status: str): + node = cluster.pools[0].nodes[0] + return node.status == status + + +def test_get_lke_clusters(get_client, create_lke_cluster): + cluster = get_client.load(LKECluster, create_lke_cluster.id) + + assert cluster._raw_json == create_lke_cluster._raw_json + + +def test_get_lke_pool(get_client, create_lke_cluster): + pytest.skip("client.load(LKENodePool, 123, 123) does not work") + + cluster = create_lke_cluster + + pool = get_client.load(LKENodePool, cluster.pools[0].id, cluster.id) + + assert cluster.pools[0]._raw_json == pool + + +def test_cluster_dashboard_url_view(create_lke_cluster): + cluster = create_lke_cluster + + url = send_request_when_resource_available( + 300, cluster.cluster_dashboard_url_view + ) + + assert re.search("https://+", url) + + +def test_kubeconfig_delete(create_lke_cluster): + cluster = create_lke_cluster + + cluster.kubeconfig_delete() + + +def test_lke_node_view(create_lke_cluster): + cluster = create_lke_cluster + node_id = cluster.pools[0].nodes[0].id + + node = cluster.node_view(node_id) + + assert node.status in ("ready", "not_ready") + assert node.id == node_id + assert node.instance_id + + +def test_lke_node_delete(create_lke_cluster): + cluster = create_lke_cluster + node_id = cluster.pools[0].nodes[0].id + + cluster.node_delete(node_id) + + with pytest.raises(ApiError) as err: + cluster.node_view(node_id) + assert "Not found" in str(err.json) + + +def test_lke_node_recycle(get_client, create_lke_cluster): + cluster = get_client.load(LKECluster, create_lke_cluster.id) + node = cluster.pools[0].nodes[0] + node_id = cluster.pools[0].nodes[0].id + + send_request_when_resource_available(300, cluster.node_recycle, node_id) + + wait_for_condition(10, 300, get_node_status, cluster, "not_ready") + + node = cluster.pools[0].nodes[0] + assert node.status == "not_ready" + + # wait for provisioning + wait_for_condition(10, 300, get_node_status, cluster, "ready") + + node = cluster.pools[0].nodes[0] + assert node.status == "ready" + + +def test_lke_cluster_nodes_recycle(get_client, create_lke_cluster): + cluster = create_lke_cluster + + send_request_when_resource_available(300, cluster.cluster_nodes_recycle) + + wait_for_condition(5, 120, get_node_status, cluster, "not_ready") + + node = cluster.pools[0].nodes[0] + assert node.status == "not_ready" + + +def test_lke_cluster_regenerate(create_lke_cluster): + pytest.skip( + "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" + ) + cluster = create_lke_cluster + + cluster.cluster_regenerate() + + +def test_service_token_delete(create_lke_cluster): + pytest.skip( + "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" + ) + cluster = create_lke_cluster + + cluster.service_token_delete() diff --git a/test/integration/models/test_longview.py b/test/integration/models/test_longview.py new file mode 100644 index 000000000..fcb66c609 --- /dev/null +++ b/test/integration/models/test_longview.py @@ -0,0 +1,45 @@ +import re +import time + +from linode_api4.objects import LongviewClient, LongviewSubscription + + +def test_get_longview_client(get_client, create_longview_client): + longview = get_client.load(LongviewClient, create_longview_client.id) + + assert longview.id == create_longview_client.id + + +def test_update_longview_label(get_client, create_longview_client): + longview = get_client.load(LongviewClient, create_longview_client.id) + old_label = longview.label + + label = "updated_longview_label" + + longview.label = label + + longview.save() + + assert longview.label != old_label + + +def test_delete_client(get_client, create_longview_client): + client = get_client + label = "TestSDK-longview" + longview_client = client.longview.client_create(label=label) + + time.sleep(5) + + res = longview_client.delete() + + assert res + + +def test_get_longview_subscription(get_client, create_longview_client): + subs = get_client.longview.subscriptions() + sub = get_client.load(LongviewSubscription, subs[0].id) + + assert "clients_included" in str(subs.first().__dict__) + + assert re.search("[0-9]+", str(sub.price.hourly)) + assert re.search("[0-9]+", str(sub.price.monthly)) diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py new file mode 100644 index 000000000..0f1dfcd87 --- /dev/null +++ b/test/integration/models/test_networking.py @@ -0,0 +1,12 @@ +from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range + + +def test_get_networking_rules(get_client, create_firewall): + firewall = get_client.load(Firewall, create_firewall.id) + + rules = firewall.get_rules() + + assert "inbound" in str(rules) + assert "inbound_policy" in str(rules) + assert "outbound" in str(rules) + assert "outbound_policy" in str(rules) diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/test_nodebalancer.py new file mode 100644 index 000000000..6489c0a5e --- /dev/null +++ b/test/integration/models/test_nodebalancer.py @@ -0,0 +1,119 @@ +import re + +import pytest + +from linode_api4 import ApiError +from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode + + +@pytest.fixture(scope="session") +def create_linode_with_private_ip(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = "linode_with_privateip" + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", + chosen_region, + image="linode/debian9", + label=label, + private_ip=True, + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="session") +def create_nb_config(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = "nodebalancer_test" + + nb = client.nodebalancer_create(region=chosen_region, label=label) + + config = nb.config_create() + + yield config + + config.delete() + nb.delete() + + +def test_get_nodebalancer_config(get_client, create_nb_config): + config = get_client.load( + NodeBalancerConfig, + create_nb_config.id, + create_nb_config.nodebalancer_id, + ) + + +def test_create_nb_node( + get_client, create_nb_config, create_linode_with_private_ip +): + config = get_client.load( + NodeBalancerConfig, + create_nb_config.id, + create_nb_config.nodebalancer_id, + ) + linode = create_linode_with_private_ip + address = [a for a in linode.ipv4 if re.search("192.+", a)][0] + node = config.node_create( + "node_test", address + ":80", weight=50, mode="accept" + ) + + assert re.search("192.168.+:[0-9]+", node.address) + assert "node_test" == node.label + + +def test_get_nb_node(get_client, create_nb_config): + node = get_client.load( + NodeBalancerNode, + create_nb_config.nodes[0].id, + (create_nb_config.id, create_nb_config.nodebalancer_id), + ) + + +def test_update_nb_node(get_client, create_nb_config): + config = get_client.load( + NodeBalancerConfig, + create_nb_config.id, + create_nb_config.nodebalancer_id, + ) + node = config.nodes[0] + node.label = "ThisNewLabel" + node.weight = 50 + node.mode = "accept" + node.save() + + node_updated = get_client.load( + NodeBalancerNode, + create_nb_config.nodes[0].id, + (create_nb_config.id, create_nb_config.nodebalancer_id), + ) + + assert "ThisNewLabel" == node_updated.label + assert 50 == node_updated.weight + assert "accept" == node_updated.mode + + +def test_delete_nb_node(get_client, create_nb_config): + config = get_client.load( + NodeBalancerConfig, + create_nb_config.id, + create_nb_config.nodebalancer_id, + ) + node = config.nodes[0] + + node.delete() + + with pytest.raises(ApiError) as e: + get_client.load( + NodeBalancerNode, + create_nb_config.nodes[0].id, + (create_nb_config.id, create_nb_config.nodebalancer_id), + ) + assert "Not Found" in str(e.json) diff --git a/test/integration/models/test_tag.py b/test/integration/models/test_tag.py new file mode 100644 index 000000000..aa2596633 --- /dev/null +++ b/test/integration/models/test_tag.py @@ -0,0 +1,21 @@ +from test.integration.helpers import get_test_label + +import pytest + +from linode_api4.objects import Instance, Tag + + +@pytest.fixture +def create_tag(get_client): + unique_tag = get_test_label() + "_tag" + tag = get_client.tag_create(unique_tag) + + yield tag + + tag.delete() + + +def test_get_tag(get_client, create_tag): + tag = get_client.load(Tag, create_tag.id) + + assert tag.id == create_tag.id diff --git a/test/integration/models/test_volume.py b/test/integration/models/test_volume.py new file mode 100644 index 000000000..25114178e --- /dev/null +++ b/test/integration/models/test_volume.py @@ -0,0 +1,104 @@ +import time +from test.integration.conftest import get_token +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) + +import pytest + +from linode_api4 import LinodeClient +from linode_api4.objects import Volume + + +@pytest.fixture(scope="session") +def create_linode_for_volume(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + linode_instance, password = client.linode.instance_create( + "g5-standard-4", chosen_region, image="linode/debian9", label=label + ) + + yield linode_instance + + linode_instance.delete() + + +def get_status(volume: Volume, status: str): + client = LinodeClient(token=get_token()) + volume = client.load(Volume, volume.id) + return volume.status == status + + +def test_get_volume(get_client, create_volume): + volume = get_client.load(Volume, create_volume.id) + + assert volume.id == create_volume.id + + +def test_update_volume_tag(get_client, create_volume): + volume = create_volume + tag_1 = "volume_test_tag1" + tag_2 = "volume_test_tag2" + + volume.tags = [tag_1, tag_2] + volume.save() + + volume = get_client.load(Volume, create_volume.id) + + assert [tag_1, tag_2] == volume.tags + + +def test_volume_resize(get_client, create_volume): + volume = get_client.load(Volume, create_volume.id) + + wait_for_condition(10, 100, get_status, volume, "active") + + res = retry_sending_request(5, volume.resize, 21) + + assert res + + +def test_volume_clone_and_delete(get_client, create_volume): + volume = get_client.load(Volume, create_volume.id) + label = get_test_label() + + wait_for_condition(10, 100, get_status, volume, "active") + + new_volume = retry_sending_request(5, volume.clone, label) + + assert label == new_volume.label + + res = retry_sending_request(5, new_volume.delete) + + assert res, "new volume deletion failed" + + +def test_attach_volume_to_linode( + get_client, create_volume, create_linode_for_volume +): + volume = create_volume + linode = create_linode_for_volume + + res = retry_sending_request(5, volume.attach, linode.id) + + assert res + + +def test_detach_volume_to_linode( + get_client, create_volume, create_linode_for_volume +): + volume = create_volume + linode = create_linode_for_volume + + res = retry_sending_request(5, volume.detach) + + assert res + + # time wait for volume to detach before deletion occurs + time.sleep(30) diff --git a/tox.ini b/tox.ini index 0b51a2837..adb2aab2f 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,6 @@ deps = httpretty commands = python setup.py install - coverage run --source linode_api4 -m pytest + coverage run --source linode_api4 -m pytest test/objects coverage report pylint linode_api4 From b97ea632e3ad82006c7281837ee759125fff8527 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:09:32 -0400 Subject: [PATCH 117/379] new: Add event polling functionality (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change introduces various event-polling-related methods and classes to be reused across both official and unofficial Linode API integrations. Additionally, this change adds documentation and guide for using the new event polling system. This event polling system is derived from the event polling system implemented in the Linode Ansible Collection, but is _not_ backwards compatbile. --- docs/guides/event_polling.rst | 104 +++++++++++ docs/index.rst | 2 + docs/linode_api4/linode_client.rst | 9 + docs/linode_api4/objects/models.rst | 1 - docs/linode_api4/polling.rst | 12 ++ linode_api4/__init__.py | 1 + linode_api4/groups/__init__.py | 1 + linode_api4/groups/polling.py | 90 +++++++++ linode_api4/linode_client.py | 3 + linode_api4/polling.py | 226 +++++++++++++++++++++++ requirements.txt | 1 + setup.py | 1 + test/objects/polling_test.py | 274 ++++++++++++++++++++++++++++ 13 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 docs/guides/event_polling.rst create mode 100644 docs/linode_api4/polling.rst create mode 100644 linode_api4/groups/polling.py create mode 100644 linode_api4/polling.py create mode 100644 test/objects/polling_test.py diff --git a/docs/guides/event_polling.rst b/docs/guides/event_polling.rst new file mode 100644 index 000000000..b9a782f3c --- /dev/null +++ b/docs/guides/event_polling.rst @@ -0,0 +1,104 @@ +Polling for Events +================== + +There are often situations where an API request will trigger a +long-running operation (e.g. Instance shutdown) that will run +after the request has been made. These operations are tracked +through `Linode Account Events`_ which reflect the target entity, +progress, and status of these operations. + +.. _Linode Account Events: https://www.linode.com/docs/api/account/#events-list + +There are often cases where you would like for your application to +halt until these operations have succeeded. The most reliable and +efficient way to achieve this is by using the :py:class:`EventPoller` +object. + +Polling on Basic Operations +--------------------------- + +In order to poll for an operation, we must create an :py:class:`EventPoller` +object *before* the endpoint that triggers the operation has been called. + +Assuming a :py:class:`LinodeClient` object has already been created with the name +"client" and an :py:class:`Instance` object has already been created with the name "my_instance", +an :py:class:`EventPoller` can be created using the +:meth:`LinodeClient.polling.event_poller_create(...) ` +method:: + + poller = client.polling.event_poller_create( + "linode", # The type of the target entity + "linode_shutdown", # The action to poll for + entity_id=my_instance.id, # The ID of your Linode Instance + ) + +Valid values for the `type` and `action` fields can be found in the `Events Response Documentation`_. + +.. _Events Response Documentation: https://www.linode.com/docs/api/account/#events-list__responses + +From here, we can send the request to trigger the long-running operation:: + + my_instance.shutdown() + +To wait for this operation to finish, we can call the +:meth:`poller.wait_for_next_event_finished(...) ` +method:: + + poller.wait_for_next_event_finished() + +The :py:class:`timeout` (default 240) and :py:class:`interval` (default 5) arguments can optionally be used to configure the timeout +and poll frequency for this operation. + +Bringing this together, we get the following:: + + from linode_api4 import LinodeClient, Instance + + # Construct a client + client = LinodeClient("MY_LINODE_TOKEN") + + # Fetch an existing Linode Instance + my_instance = client.load(Instance, 12345) + + # Create the event poller + poller = client.polling.event_poller_create( + "linode", # The type of the target entity + "linode_shutdown", # The action to poll for + entity_id=my_instance.id, # The ID of your Linode Instance + ) + + # Shutdown the Instance + my_instance.shutdown() + + # Wait until the event has finished + poller.wait_for_next_event_finished() + + print("Linode has been successfully shutdown!") + +Polling for an Entity to be Free +-------------------------------- + +In many cases, certain operations cannot be run until any other operations running on a resource have +been completed. To ensure these operation are run reliably and do not encounter conflicts, +you can use the +:meth:`LinodeClient.polling.wait_for_entity_free(...) ` method +to wait until a resource has no running or queued operations. + +For example:: + + # Construct a client + client = LinodeClient("MY_LINODE_TOKEN") + + # Load an existing instance + my_instance = client.load(Instance, 12345) + + # Wait until the Linode is not busy + client.polling.wait_for_entity_free( + "linode", + my_instance.id + ) + + # Boot the Instance + my_instance.boot() + +The :py:class:`timeout` (default 240) and :py:class:`interval` (default 5) arguments can optionally be used to configure the timeout +and poll frequency for this operation. diff --git a/docs/index.rst b/docs/index.rst index 6a3fc724c..5fb4ad6a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,9 +32,11 @@ Table of Contents guides/getting_started guides/core_concepts + guides/event_polling guides/oauth linode_api4/linode_client linode_api4/login_client linode_api4/objects/models + linode_api4/polling linode_api4/paginated_list linode_api4/objects/filtering diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index 172e00864..b87a6a18f 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -146,6 +146,15 @@ with buckets and objects, use the s3 API directly with a library like `boto3`_. .. _boto3: https://github.com/boto/boto3 +PollingGroup +^^^^^^^^^^^^ + +Includes methods related to account event polling. + +.. autoclass:: linode_api4.linode_client.PollingGroup + :members: + :special-members: + ProfileGroup ^^^^^^^^^^^^ diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 089651613..7ea664940 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -139,4 +139,3 @@ Volume Models :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name :undoc-members: :inherited-members: - diff --git a/docs/linode_api4/polling.rst b/docs/linode_api4/polling.rst new file mode 100644 index 000000000..6f5d956ea --- /dev/null +++ b/docs/linode_api4/polling.rst @@ -0,0 +1,12 @@ +Event Polling +========== + +This project exposes a framework for dynamically polling on long-running Linode Events. + +See the :doc:`Event Polling Guide<../guides/event_polling>` for more details. + +EventPoller class +------------------- + +.. autoclass:: linode_api4.EventPoller + :members: diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py index bd1d6023a..b347b607d 100644 --- a/linode_api4/__init__.py +++ b/linode_api4/__init__.py @@ -4,3 +4,4 @@ from linode_api4.linode_client import LinodeClient from linode_api4.login_client import LinodeLoginClient, OAuthScopes from linode_api4.paginated_list import PaginatedList +from linode_api4.polling import EventPoller diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index bf815fffd..e3d7658fe 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -11,6 +11,7 @@ from .networking import * from .nodebalancer import * from .object_storage import * +from .polling import * from .profile import * from .region import * from .support import * diff --git a/linode_api4/groups/polling.py b/linode_api4/groups/polling.py new file mode 100644 index 000000000..3eaa0edda --- /dev/null +++ b/linode_api4/groups/polling.py @@ -0,0 +1,90 @@ +import polling + +from linode_api4.groups import Group +from linode_api4.objects.account import Event +from linode_api4.polling import EventPoller, TimeoutContext + + +class PollingGroup(Group): + """ + This group contains various helper functions for polling on Linode events. + """ + + def event_poller_create( + self, + entity_type: str, + action: str, + entity_id: int = None, + ) -> EventPoller: + """ + Creates a new instance of the EventPoller class. + + :param entity_type: The type of the entity to poll for events on. + Valid values for this field can be found here: https://www.linode.com/docs/api/account/#events-list__responses + :type entity_type: str + :param action: The action that caused the Event to poll for. + Valid values for this field can be found here: https://www.linode.com/docs/api/account/#events-list__responses + :type action: str + :param entity_id: The ID of the entity to poll for. + :type entity_id: int + :param poll_interval: The interval in seconds to wait between polls. + :type poll_interval: int + + :returns: The new EventPoller object. + :rtype: EventPoller + """ + + return EventPoller( + self.client, + entity_type, + action, + entity_id=entity_id, + ) + + def wait_for_entity_free( + self, + entity_type: str, + entity_id: int, + timeout: int = 240, + interval: int = 5, + ): + """ + Waits for all events relevant events to not be scheduled or in-progress. + + :param entity_type: The type of the entity to poll for events on. + Valid values for this field can be found here: https://www.linode.com/docs/api/account/#events-list__responses + :type entity_type: str + :param entity_id: The ID of the entity to poll for. + :type entity_id: int + :param timeout: The timeout in seconds for this polling operation. + :type timeout: int + :param interval: The interval in seconds to wait between polls. + :type interval: int + """ + + timeout_ctx = TimeoutContext(timeout_seconds=timeout) + + api_filter = { + "+order": "desc", + "+order_by": "created", + "entity.id": entity_id, + "entity.type": entity_type, + } + + def poll_func(): + events = self.client.get("/account/events", filters=api_filter)[ + "data" + ] + return all( + event["status"] not in ("scheduled", "started") + for event in events + ) + + if poll_func(): + return + + polling.poll( + poll_func, + step=interval, + timeout=timeout_ctx.seconds_remaining, + ) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index a067a1755..f1c7c8e75 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -168,6 +168,9 @@ def __init__( #: Access methods related to Images - See :any:`ImageGroup` for more information. self.images = ImageGroup(self) + #: Access methods related to Event polling - See :any:`PollingGroup` for more information. + self.polling = PollingGroup(self) + @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( diff --git a/linode_api4/polling.py b/linode_api4/polling.py new file mode 100644 index 000000000..537239635 --- /dev/null +++ b/linode_api4/polling.py @@ -0,0 +1,226 @@ +import datetime +from typing import Any, Dict, List, Optional + +import polling + +from linode_api4.objects import Event + + +class TimeoutContext: + """ + TimeoutContext should be used by polling resources to track their provisioning time. + """ + + def __init__(self, timeout_seconds=120): + self._start_time = datetime.datetime.now() + self._timeout_seconds = timeout_seconds + + def start(self, start_time=datetime.datetime.now()): + """ + Sets the timeout start time to the current time. + + :param start_time: The moment when the context started. + :type start_time: datetime + """ + self._start_time = start_time + + def extend(self, seconds: int): + """ + Extends the timeout window. + + :param seconds: The number of seconds to extend the timeout period by. + :type seconds: int + """ + self._timeout_seconds += seconds + + @property + def expired(self): + """ + Whether the current timeout period has been exceeded. + + :returns: Whether this context is expired. + :rtype: bool + """ + return self.seconds_remaining < 0 + + @property + def valid(self): + """ + Whether the current timeout period has not been exceeded. + + :returns: Whether this context is valid. + :rtype: bool + """ + return not self.expired + + @property + def seconds_remaining(self): + """ + The number of seconds until the timeout period has expired. + + :returns: The number of seconds remaining in this context. + :rtype: int + """ + return self._timeout_seconds - self.seconds_since_started + + @property + def seconds_since_started(self): + """ + The number of seconds since the timeout period started. + + :returns: The number of seconds since the context started. + :rtype: int + """ + return (datetime.datetime.now() - self._start_time).seconds + + +class EventPoller: + """ + EventPoller allows modules to dynamically poll for Linode events + """ + + def __init__( + self, + client: "LinodeClient", + entity_type: str, + action: str, + entity_id: int = None, + ): + self._client = client + self._entity_type = entity_type + self._entity_id = entity_id + self._action = action + + # Initialize with an empty cache if no entity is specified + if self._entity_id is None: + self._previous_event_cache = {} + return + + # We only want the first page of this response + result = client.get("/account/events", filters=self._build_filter()) + + self._previous_event_cache = {v["id"]: v for v in result["data"]} + + def _build_filter(self) -> Dict[str, Any]: + """Generates a filter dict to use in HTTP requests""" + return { + "+order": "asc", + "+order_by": "created", + "entity.id": self._entity_id, + "entity.type": self._entity_type, + "action": self._action, + } + + def set_entity_id(self, entity_id: int) -> None: + """ + Sets the ID of the entity to filter on. + This is useful for create operations where + the entity id might not be known in __init__. + + :param entity_id: The ID of the entity to poll for. + :type entity_id: int + """ + self._entity_id = entity_id + + def _attempt_merge_event_into_cache(self, event: Dict[str, Any]): + """ + Attempts to merge the given event into the event cache. + """ + + if event["id"] in self._previous_event_cache: + return + + self._previous_event_cache[event["id"]] = event + + def _check_has_new_event( + self, events: List[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + """ + If a new event is found in the given list, return it. + """ + + for event in events: + # Ignore cached events + if event["id"] in self._previous_event_cache: + continue + + return event + + return None + + def wait_for_next_event( + self, timeout: int = 240, interval: int = 5 + ) -> Event: + """ + Waits for and returns the next event matching the + poller's configuration. + + :param timeout: The timeout in seconds before this polling operation will fail. + :type timeout: int + :param interval: The time in seconds to wait between polls. + :type interval: int + + :returns: The resulting event. + :rtype: Event + """ + result_event: Dict[str, Any] = {} + + def poll_func(): + new_event = self._check_has_new_event( + self._client.get( + "/account/events", filters=self._build_filter() + )["data"] + ) + + event_exists = new_event is not None + + if event_exists: + nonlocal result_event + result_event = new_event + self._attempt_merge_event_into_cache(new_event) + + return event_exists + + if poll_func(): + return Event(self._client, result_event["id"], json=result_event) + + polling.poll( + poll_func, + step=interval, + timeout=timeout, + ) + + return Event(self._client, result_event["id"], json=result_event) + + def wait_for_next_event_finished( + self, timeout: int = 240, interval: int = 5 + ) -> Event: + """ + Waits for the next event to enter status `finished` or `notification`. + + :param timeout: The timeout in seconds before this polling operation will fail. + :type timeout: int + :param interval: The time in seconds to wait between polls. + :type interval: int + + :returns: The resulting event. + :rtype: Event + """ + + timeout_ctx = TimeoutContext(timeout_seconds=timeout) + event = self.wait_for_next_event(timeout_ctx.seconds_remaining) + + def poll_func(): + event._api_get() + return event.status in ["finished", "notification"] + + if poll_func(): + return event + + polling.poll( + poll_func, + step=interval, + timeout=timeout_ctx.seconds_remaining, + ) + + return event diff --git a/requirements.txt b/requirements.txt index 3d78232b6..183da90a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ httplib2 enum34 requests +polling>=0.3.2 \ No newline at end of file diff --git a/setup.py b/setup.py index 44a78c465..25c0700b0 100755 --- a/setup.py +++ b/setup.py @@ -121,6 +121,7 @@ def bake_version(v): install_requires=[ "requests", + "polling" ], extras_require={ diff --git a/test/objects/polling_test.py b/test/objects/polling_test.py new file mode 100644 index 000000000..b4d3a88cd --- /dev/null +++ b/test/objects/polling_test.py @@ -0,0 +1,274 @@ +import json + +import httpretty +import pytest + +from linode_api4 import LinodeClient + + +class TestPolling: + @pytest.fixture(scope="class") + def client(self): + return LinodeClient("testing", base_url="https://localhost") + + @staticmethod + def body_event_status(status: str, action: str = "linode_shutdown"): + return { + "action": action, + "entity": { + "id": 11111, + "type": "linode", + }, + "id": 123, + "status": status, + } + + @staticmethod + def body_event_list_empty(): + return {"data": [], "page": 1, "pages": 1, "results": 0} + + @staticmethod + def body_event_list_status(status: str, action="linode_shutdown"): + body = TestPolling.body_event_list_empty() + body["data"].append( + TestPolling.body_event_status(status, action=action) + ) + body["results"] = 1 + + return body + + @httpretty.activate + def test_wait_for_entity_free( + self, + client, + ): + """ + Tests that the wait_for_entity_free method works as expected. + """ + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events", + responses=[ + httpretty.Response( + body=json.dumps(self.body_event_list_status("started")), + status=200, + ), + httpretty.Response( + body=json.dumps(self.body_event_list_status("finished")), + status=200, + ), + httpretty.Response( + body=json.dumps(self.body_event_list_status("finished")), + status=200, + ), + ], + ) + + client.polling.wait_for_entity_free( + "linode", + 11111, + 10, + 0.1, + ) + + assert len(httpretty.latest_requests()) == 2 + + @httpretty.activate + def test_wait_for_entity_free_notification( + self, + client, + ): + """ + Tests that the wait_for_entity_free method works as expected with a notification event. + """ + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events", + responses=[ + httpretty.Response( + body=json.dumps( + self.body_event_list_status("notification") + ), + status=200, + ), + httpretty.Response( + body=json.dumps( + self.body_event_list_status("notification") + ), + status=200, + ), + ], + ) + + client.polling.wait_for_entity_free( + "linode", + 11111, + 10, + 0.1, + ) + + assert len(httpretty.latest_requests()) == 1 + + for r in httpretty.latest_requests(): + filter_header = r.headers["X-Filter"] + assert '"entity.type": "linode"' in filter_header + assert '"entity.id": 11111' in filter_header + + @httpretty.activate + def test_wait_for_event_finished( + self, + client, + ): + """ + Tests that the EventPoller.wait_for_event_finished method works as expected. + """ + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events/123", + responses=[ + httpretty.Response( + body=json.dumps(self.body_event_status("started")), + ), + httpretty.Response( + body=json.dumps(self.body_event_status("started")), + ), + httpretty.Response( + body=json.dumps(self.body_event_status("finished")), + ), + ], + ) + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events", + responses=[ + httpretty.Response( + body=json.dumps(self.body_event_list_empty()), + status=200, + ), + httpretty.Response( + body=json.dumps(self.body_event_list_status("started")), + status=200, + ), + httpretty.Response( + body=json.dumps(self.body_event_list_status("finished")), + status=200, + ), + ], + ) + + result = client.polling.event_poller_create( + "linode", "linode_shutdown", entity_id=11111 + ).wait_for_next_event_finished(interval=0.1) + + latest_requests = httpretty.latest_requests() + + list_requests = [ + v for v in latest_requests if v.path == "/account/events" + ] + + get_requests = [ + v for v in latest_requests if v.path == "/account/events/123" + ] + + for r in list_requests: + filter_header = r.headers["X-Filter"] + assert '"entity.type": "linode"' in filter_header + assert '"entity.id": 11111' in filter_header + + assert len(list_requests) == 2 + assert len(get_requests) == 3 + assert result.entity.id == 11111 + assert result.status == "finished" + + @httpretty.activate + def test_wait_for_event_finished_creation( + self, + client, + ): + """ + Tests that the EventPoller.wait_for_event_finished method + works as expected on newly created entities. + """ + + action = "linode_create" + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events/123", + responses=[ + httpretty.Response( + body=json.dumps( + self.body_event_status("started", action=action) + ), + ), + httpretty.Response( + body=json.dumps( + self.body_event_status("started", action=action) + ), + ), + httpretty.Response( + body=json.dumps( + self.body_event_status("finished", action=action) + ), + ), + ], + ) + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events", + responses=[ + httpretty.Response( + body=json.dumps(self.body_event_list_empty()), + status=200, + ), + httpretty.Response( + body=json.dumps( + self.body_event_list_status("started", action=action) + ), + status=200, + ), + httpretty.Response( + body=json.dumps( + self.body_event_list_status("finished", action=action) + ), + status=200, + ), + ], + ) + + poller = client.polling.event_poller_create( + "linode", + "linode_create", + ) + + # Pretend we created an instance here + instance_id = 11111 + + poller.set_entity_id(instance_id) + + result = poller.wait_for_next_event_finished(interval=0.1) + + latest_requests = httpretty.latest_requests() + + list_requests = [ + v for v in latest_requests if v.path == "/account/events" + ] + + get_requests = [ + v for v in latest_requests if v.path == "/account/events/123" + ] + + for r in list_requests: + filter_header = r.headers["X-Filter"] + assert '"entity.type": "linode"' in filter_header + assert '"entity.id": 11111' in filter_header + + assert len(list_requests) == 2 + assert len(get_requests) == 3 + assert result.entity.id == 11111 + assert result.status == "finished" From 88e489e95777513ad257df277b16a1a48d777bec Mon Sep 17 00:00:00 2001 From: ykim-1 <126618609+ykim-1@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:36:55 -0700 Subject: [PATCH 118/379] Update README and workflow_dispatch (#295) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description updating README and workflow_dispatch remove /acctest command ## ✔️ How to Test Refer to Testing section in README **How do I run the relevant unit/integration tests?** ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- .github/workflows/e2e-test-pr-command.yml | 19 -------------- .github/workflows/e2e-test-pr.yml | 28 ++++++++++++-------- Makefile | 12 ++++++++- README.rst | 31 ++++++++++++++++++++++- 4 files changed, 58 insertions(+), 32 deletions(-) delete mode 100644 .github/workflows/e2e-test-pr-command.yml diff --git a/.github/workflows/e2e-test-pr-command.yml b/.github/workflows/e2e-test-pr-command.yml deleted file mode 100644 index 3b52a695b..000000000 --- a/.github/workflows/e2e-test-pr-command.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: AccTest Command - -on: - issue_comment: - types: [created] - -jobs: - acctest-command: - runs-on: ubuntu-latest - if: ${{ github.event.issue.pull_request }} - steps: - - name: Slash Command Dispatch - uses: peter-evans/slash-command-dispatch@v1.2.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - issue-type: pull-request - commands: acctest - named-args: true - permission: write diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 020874a66..9ac6f8639 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -1,18 +1,24 @@ on: pull_request: - repository_dispatch: - types: [acctest-command] + workflow_dispatch: + inputs: + test_path: + description: 'Enter specific test path. E.g. linode_client/test_linode_client.py, models/test_account.py' + required: false + sha: + description: 'The hash value of the commit.' + required: true + pull_request_number: + description: 'The number of the PR.' + required: false name: PR E2E Tests jobs: - # Maintainer has commented /acctest on a pull request integration-fork-ubuntu: runs-on: ubuntu-latest if: - github.event_name == 'repository_dispatch' && - github.event.client_payload.slash_command.sha != '' && - github.event.client_payload.pull_request.head.sha == github.event.client_payload.slash_command.sha + github.event_name == 'workflow_dispatch' && inputs.sha != '' steps: - uses: actions-ecosystem/action-regex-match@v2 @@ -26,7 +32,7 @@ jobs: - name: Checkout PR uses: actions/checkout@v3 with: - ref: ${{ github.event.client_payload.slash_command.sha }} + ref: ${{ inputs.sha }} - name: Update system packages run: sudo apt-get update -y @@ -47,16 +53,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: make testint + - run: make INTEGRATION_TEST_PATH="${{ inputs.test_path }}" testint if: ${{ steps.validate-tests.outputs.match == '' }} env: LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} - - uses: actions/github-script@v5 + - uses: actions/github-script@v6 id: update-check-run - if: ${{ always() }} + if: ${{ inputs.pull_request_number != '' }} env: - number: ${{ github.event.client_payload.pull_request.number }} + number: ${{ inputs.pull_request_number }} job: ${{ github.job }} conclusion: ${{ job.status }} with: diff --git a/Makefile b/Makefile index cf6c2431d..992927606 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,16 @@ PYTHON ?= python3 INTEGRATION_TEST_PATH := +TEST_CASE_COMMAND := +MODEL_COMMAND := + +ifdef TEST_CASE +TEST_CASE_COMMAND = -k $(TEST_CASE) +endif + +ifdef TEST_MODEL +MODEL_COMMAND = models/$(TEST_MODEL) +endif @PHONEY: clean clean: @@ -50,4 +60,4 @@ lint: @PHONEY: testint testint: - python3 -m pytest test/integration/ + python3 -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} diff --git a/README.rst b/README.rst index f04105bdf..d00012285 100644 --- a/README.rst +++ b/README.rst @@ -101,7 +101,7 @@ Contributing Tests ----- -Tests live in the ``tests`` directory. When invoking tests, make sure you are +Tests live in the ``test`` directory. When invoking tests, make sure you are in the root directory of this project. To run the full suite across all supported python versions, use tox_: @@ -133,6 +133,35 @@ from the api base url that should be returned, for example:: .. _tox: http://tox.readthedocs.io + +Integration Tests +----------- +Integration tests live in the ``test/integration`` directory. + +Pre-requisite +^^^^^^^^^^^^^^^^^ +Export Linode API token as `LINODE_CLI_TOKEN` before running integration tests:: + + export LINODE_TOKEN = $(your_token) + +Running the tests +^^^^^^^^^^^^^^^^^ +Run the tests locally using the make command. Run the entire test suite using command below:: + + make testint + +To run a specific package, use environment variable `INTEGRATION_TEST_PATH` with `testint` command:: + + make INTEGRATION_TEST_PATH="linode_client" testint + +To run a specific model test suite, set the environment variable `TEST_MODEL` using file name in `integration/models`:: + + make TEST_MODEL="test_account.py" testint + +Lastly to run a specific test case use environment variable `TEST_CASE` with `testint` command:: + + make TEST_CASE=test_get_domain_record testint + Documentation ------------- From f45ae85df3deaa3f50028975068a213e7d14789d Mon Sep 17 00:00:00 2001 From: ykim-1 <126618609+ykim-1@users.noreply.github.com> Date: Fri, 9 Jun 2023 10:38:12 -0700 Subject: [PATCH 119/379] Add integration tests for Linode Client retry (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Tests added for LinodeClient retry mechanism - Moved tests in linode_client_test.py - Added tests for other allowed methods: PUT, POST, DELETE ## ✔️ How to Test pytest test/integration/linode_client/test_retry.py **How do I run the relevant unit/integration tests?** ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- test/integration/linode_client/test_retry.py | 175 +++++++++++++++++++ test/linode_client_test.py | 121 +------------ 2 files changed, 176 insertions(+), 120 deletions(-) create mode 100644 test/integration/linode_client/test_retry.py diff --git a/test/integration/linode_client/test_retry.py b/test/integration/linode_client/test_retry.py new file mode 100644 index 000000000..29fd1d452 --- /dev/null +++ b/test/integration/linode_client/test_retry.py @@ -0,0 +1,175 @@ +from test.integration.conftest import get_token + +import httpretty + +from linode_api4 import ApiError, LinodeClient + +""" +Tests for retrying on intermittent errors. + +.. warning:: + This test class _does not_ follow normal testing conventions for this project, + as requests are not automatically mocked. Only add tests to this class if they + pertain to the retry logic, and make sure you mock the requests calls yourself + (or else they will make real requests and those won't work). +""" +ERROR_RESPONSES = [ + httpretty.Response( + body="{}", + status=408, + ), + httpretty.Response( + body="{}", + status=429, + ), + httpretty.Response( + body="{}", + status=200, + ), +] + + +def get_retry_client(): + client = LinodeClient(token=get_token(), base_url="https://localhost") + # sidestep the validation to do immediate retries so tests aren't slow + client.retry_rate_limit_interval = 0.1 + return client + + +@httpretty.activate +def test_get_retry_statuses(): + """ + Tests that retries work as expected on 408 and 429 responses. + """ + + httpretty.register_uri( + httpretty.GET, "https://localhost/test", responses=ERROR_RESPONSES + ) + + get_retry_client().get("/test") + + assert len(httpretty.latest_requests()) == 3 + + +@httpretty.activate +def test_put_retry_statuses(): + """ + Tests that retries work as expected on 408 and 429 responses. + """ + + httpretty.register_uri( + httpretty.PUT, "https://localhost/test", responses=ERROR_RESPONSES + ) + + get_retry_client().put("/test") + + assert len(httpretty.latest_requests()) == 3 + + +@httpretty.activate +def test_post_retry_statuses(): + httpretty.register_uri( + httpretty.POST, "https://localhost/test", responses=ERROR_RESPONSES + ) + + get_retry_client.post("/test") + + assert len(httpretty.latest_requests()) == 3 + + +@httpretty.activate +def test_delete_retry_statuses(): + httpretty.register_uri( + httpretty.DELETE, "https://localhost/test", responses=ERROR_RESPONSES + ) + + get_retry_client().delete("/test") + + assert len(httpretty.latest_requests()) == 3 + + +@httpretty.activate +def test_retry_max(): + """ + Tests that retries work as expected on 408 and 429 responses. + """ + + httpretty.register_uri( + httpretty.GET, + "https://localhost/test", + responses=[ + httpretty.Response( + body="{}", + status=408, + ), + httpretty.Response( + body="{}", + status=429, + ), + httpretty.Response( + body="{}", + status=429, + ), + ], + ) + + client = get_retry_client() + client.retry_max = 2 + + try: + client.get("/test") + except ApiError as err: + assert err.status == 429 + else: + raise RuntimeError("Expected retry error after exceeding max retries") + + assert len(httpretty.latest_requests()) == 3 + + +@httpretty.activate +def test_retry_disable(): + """ + Tests that retries can be disabled. + """ + + httpretty.register_uri( + httpretty.GET, + "https://localhost/test", + responses=[ + httpretty.Response( + body="{}", + status=408, + ), + ], + ) + + client = get_retry_client() + client.retry = False + + try: + client.get("/test") + except ApiError as e: + assert e.status == 408 + else: + raise RuntimeError("Expected 408 error to be raised") + + assert len(httpretty.latest_requests()) == 1 + + +@httpretty.activate +def test_retry_works_with_integer_interval_value(): + """ + Tests that retries work as expected on 408 and 429 responses. + """ + + httpretty.register_uri( + httpretty.GET, "https://localhost/test", responses=ERROR_RESPONSES + ) + + client = get_retry_client() + client.retry_max = 2 + client.retry_rate_limit_interval = 1 + + client.get("/test") + + assert len(httpretty.latest_requests()) == 3 diff --git a/test/linode_client_test.py b/test/linode_client_test.py index 4c0f9169c..1b4318f6f 100644 --- a/test/linode_client_test.py +++ b/test/linode_client_test.py @@ -1,11 +1,7 @@ from datetime import datetime from test.base import ClientBaseCase -from unittest import TestCase -import httpretty -import pytest - -from linode_api4 import ApiError, LinodeClient, LongviewSubscription +from linode_api4 import LongviewSubscription from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress from linode_api4.objects.object_storage import ( @@ -1064,118 +1060,3 @@ def test_ipv6_ranges(self): ranges = self.client.networking.ipv6_ranges() self.assertEqual(len(ranges), 1) self.assertEqual(ranges[0].range, "2600:3c01::") - - -class LinodeClientRateLimitRetryTest(TestCase): - """ - Tests for retrying on intermittent errors. - - .. warning:: - This test class _does not_ follow normal testing conventions for this project, - as requests are not automatically mocked. Only add tests to this class if they - pertain to the retry logic, and make sure you mock the requests calls yourself - (or else they will make real requests and those won't work). - """ - - def get_retry_client(self): - client = LinodeClient("testing", base_url="https://localhost") - # sidestep the validation to do immediate retries so tests aren't slow - client.retry_rate_limit_interval = 0.1 - return client - - @httpretty.activate - def test_retry_statuses(self): - """ - Tests that retries work as expected on 408 and 429 responses. - """ - - httpretty.register_uri( - httpretty.GET, - "https://localhost/test", - responses=[ - httpretty.Response( - body="{}", - status=408, - ), - httpretty.Response( - body="{}", - status=429, - ), - httpretty.Response( - body="{}", - status=200, - ), - ], - ) - - self.get_retry_client().get("/test") - - assert len(httpretty.latest_requests()) == 3 - - @httpretty.activate - def test_retry_max(self): - """ - Tests that retries work as expected on 408 and 429 responses. - """ - - httpretty.register_uri( - httpretty.GET, - "https://localhost/test", - responses=[ - httpretty.Response( - body="{}", - status=408, - ), - httpretty.Response( - body="{}", - status=429, - ), - httpretty.Response( - body="{}", - status=429, - ), - ], - ) - - client = self.get_retry_client() - client.retry_max = 2 - - try: - client.get("/test") - except ApiError as err: - assert err.status == 429 - else: - raise RuntimeError( - "Expected retry error after exceeding max retries" - ) - - assert len(httpretty.latest_requests()) == 3 - - @httpretty.activate - def test_retry_disable(self): - """ - Tests that retries can be disabled. - """ - - httpretty.register_uri( - httpretty.GET, - "https://localhost/test", - responses=[ - httpretty.Response( - body="{}", - status=408, - ), - ], - ) - - client = self.get_retry_client() - client.retry = False - - try: - client.get("/test") - except ApiError as e: - assert e.status == 408 - else: - raise RuntimeError("Expected 408 error to be raised") - - assert len(httpretty.latest_requests()) == 1 From ccb933a87590ecf3469b1f306338e280182b75eb Mon Sep 17 00:00:00 2001 From: ykim-1 <126618609+ykim-1@users.noreply.github.com> Date: Wed, 14 Jun 2023 08:07:46 -0700 Subject: [PATCH 120/379] add pr head commit hash check (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Add PR head commit hash check ## ✔️ How to Test **What are the steps to reproduce the issue or verify the changes?** **How do I run the relevant unit/integration tests?** ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- .github/workflows/e2e-test-pr.yml | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 9ac6f8639..c08ec571d 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -24,7 +24,7 @@ jobs: - uses: actions-ecosystem/action-regex-match@v2 id: validate-tests with: - text: ${{ github.event.client_payload.slash_command.tests }} + text: ${{ inputs.test_path }} regex: '[^a-z0-9-:.\/_]' # Tests validation flags: gi @@ -34,6 +34,31 @@ jobs: with: ref: ${{ inputs.sha }} + - name: Get the hash value of the latest commit from the PR branch + uses: octokit/graphql-action@v2.x + id: commit-hash + if: ${{ inputs.pull_request_number != '' }} + with: + query: | + query PRHeadCommitHash($owner: String!, $repo: String!, $pr_num: Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number: $pr_num) { + headRef { + target { + ... on Commit { + oid + } + } + } + } + } + } + owner: ${{ github.event.repository.owner.login }} + repo: ${{ github.event.repository.name }} + pr_num: ${{ fromJSON(inputs.pull_request_number) }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Update system packages run: sudo apt-get update -y @@ -60,7 +85,7 @@ jobs: - uses: actions/github-script@v6 id: update-check-run - if: ${{ inputs.pull_request_number != '' }} + if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: number: ${{ inputs.pull_request_number }} job: ${{ github.job }} From 121a0f4631e5dbb8daee9cec03e9345982025b53 Mon Sep 17 00:00:00 2001 From: ykim-1 <126618609+ykim-1@users.noreply.github.com> Date: Wed, 14 Jun 2023 08:14:39 -0700 Subject: [PATCH 121/379] move unit tests into separate directory and modified tox.ini (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Unit tests currently are in the same directory as integration tests which causes a bit of confusion. Hence moving it to a different directory ## ✔️ How to Test tox **How do I run the relevant unit/integration tests?** ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- test/{objects => unit}/__init__.py | 0 test/{ => unit}/base.py | 3 +-- test/{ => unit}/fixtures.py | 0 test/{ => unit}/linode_client_test.py | 2 +- test/{ => unit}/login_client_test.py | 0 test/{ => unit}/objects/account_test.py | 2 +- test/{ => unit}/objects/database_test.py | 2 +- test/{ => unit}/objects/domain_test.py | 2 +- test/{ => unit}/objects/firewall_test.py | 2 +- test/{ => unit}/objects/image_test.py | 2 +- test/{ => unit}/objects/linode_test.py | 2 +- test/{ => unit}/objects/lke_test.py | 4 ++-- test/{ => unit}/objects/longview_test.py | 2 +- test/{ => unit}/objects/mapped_object_test.py | 0 test/{ => unit}/objects/networking_test.py | 4 ++-- test/{ => unit}/objects/nodebalancers_test.py | 3 +-- test/{ => unit}/objects/object_storage_test.py | 2 +- test/{ => unit}/objects/polling_test.py | 0 test/{ => unit}/objects/profile_test.py | 2 +- test/{ => unit}/objects/region_test.py | 2 +- test/{ => unit}/objects/support_test.py | 2 +- test/{ => unit}/objects/tag_test.py | 4 ++-- test/{ => unit}/objects/volume_test.py | 2 +- test/{ => unit}/paginated_list_test.py | 0 test/{ => unit}/util_test.py | 0 tox.ini | 2 +- 26 files changed, 22 insertions(+), 24 deletions(-) rename test/{objects => unit}/__init__.py (100%) rename test/{ => unit}/base.py (99%) rename test/{ => unit}/fixtures.py (100%) rename test/{ => unit}/linode_client_test.py (99%) rename test/{ => unit}/login_client_test.py (100%) rename test/{ => unit}/objects/account_test.py (99%) rename test/{ => unit}/objects/database_test.py (99%) rename test/{ => unit}/objects/domain_test.py (98%) rename test/{ => unit}/objects/firewall_test.py (98%) rename test/{ => unit}/objects/image_test.py (98%) rename test/{ => unit}/objects/linode_test.py (99%) rename test/{ => unit}/objects/lke_test.py (97%) rename test/{ => unit}/objects/longview_test.py (98%) rename test/{ => unit}/objects/mapped_object_test.py (100%) rename test/{ => unit}/objects/networking_test.py (95%) rename test/{ => unit}/objects/nodebalancers_test.py (98%) rename test/{ => unit}/objects/object_storage_test.py (99%) rename test/{ => unit}/objects/polling_test.py (100%) rename test/{ => unit}/objects/profile_test.py (98%) rename test/{ => unit}/objects/region_test.py (93%) rename test/{ => unit}/objects/support_test.py (95%) rename test/{ => unit}/objects/tag_test.py (93%) rename test/{ => unit}/objects/volume_test.py (98%) rename test/{ => unit}/paginated_list_test.py (100%) rename test/{ => unit}/util_test.py (100%) diff --git a/test/objects/__init__.py b/test/unit/__init__.py similarity index 100% rename from test/objects/__init__.py rename to test/unit/__init__.py diff --git a/test/base.py b/test/unit/base.py similarity index 99% rename from test/base.py rename to test/unit/base.py index f1e65d8ef..95bbcd0a6 100644 --- a/test/base.py +++ b/test/unit/base.py @@ -1,12 +1,11 @@ import json +from test.unit.fixtures import TestFixtures from unittest import TestCase from mock import patch from linode_api4 import LinodeClient -from .fixtures import TestFixtures - FIXTURES = TestFixtures() diff --git a/test/fixtures.py b/test/unit/fixtures.py similarity index 100% rename from test/fixtures.py rename to test/unit/fixtures.py diff --git a/test/linode_client_test.py b/test/unit/linode_client_test.py similarity index 99% rename from test/linode_client_test.py rename to test/unit/linode_client_test.py index 1b4318f6f..b04033447 100644 --- a/test/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4 import LongviewSubscription from linode_api4.objects.linode import Instance diff --git a/test/login_client_test.py b/test/unit/login_client_test.py similarity index 100% rename from test/login_client_test.py rename to test/unit/login_client_test.py diff --git a/test/objects/account_test.py b/test/unit/objects/account_test.py similarity index 99% rename from test/objects/account_test.py rename to test/unit/objects/account_test.py index 4648de12d..09aba9e7f 100644 --- a/test/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import ( Account, diff --git a/test/objects/database_test.py b/test/unit/objects/database_test.py similarity index 99% rename from test/objects/database_test.py rename to test/unit/objects/database_test.py index e32368d33..d5b84cebb 100644 --- a/test/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -1,4 +1,4 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4 import PostgreSQLDatabase from linode_api4.objects import MySQLDatabase diff --git a/test/objects/domain_test.py b/test/unit/objects/domain_test.py similarity index 98% rename from test/objects/domain_test.py rename to test/unit/objects/domain_test.py index 805e8e7f9..64376fb37 100644 --- a/test/objects/domain_test.py +++ b/test/unit/objects/domain_test.py @@ -1,4 +1,4 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import Domain, DomainRecord diff --git a/test/objects/firewall_test.py b/test/unit/objects/firewall_test.py similarity index 98% rename from test/objects/firewall_test.py rename to test/unit/objects/firewall_test.py index 3b8cc280b..a46ea2750 100644 --- a/test/objects/firewall_test.py +++ b/test/unit/objects/firewall_test.py @@ -1,4 +1,4 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import Firewall, FirewallDevice diff --git a/test/objects/image_test.py b/test/unit/objects/image_test.py similarity index 98% rename from test/objects/image_test.py rename to test/unit/objects/image_test.py index 02f392473..2f3ac610e 100644 --- a/test/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -1,6 +1,6 @@ from datetime import datetime from io import BytesIO -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from typing import BinaryIO from unittest.mock import patch diff --git a/test/objects/linode_test.py b/test/unit/objects/linode_test.py similarity index 99% rename from test/objects/linode_test.py rename to test/unit/objects/linode_test.py index c30e71043..d336478a7 100644 --- a/test/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import Config, Disk, Image, Instance, StackScript, Type diff --git a/test/objects/lke_test.py b/test/unit/objects/lke_test.py similarity index 97% rename from test/objects/lke_test.py rename to test/unit/objects/lke_test.py index c42a3de2a..5c9902b38 100644 --- a/test/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,7 +1,7 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode +from linode_api4.objects import LKECluster, LKENodePool class LKETest(ClientBaseCase): diff --git a/test/objects/longview_test.py b/test/unit/objects/longview_test.py similarity index 98% rename from test/objects/longview_test.py rename to test/unit/objects/longview_test.py index c5002eba8..10f3388eb 100644 --- a/test/objects/longview_test.py +++ b/test/unit/objects/longview_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import ( LongviewClient, diff --git a/test/objects/mapped_object_test.py b/test/unit/objects/mapped_object_test.py similarity index 100% rename from test/objects/mapped_object_test.py rename to test/unit/objects/mapped_object_test.py diff --git a/test/objects/networking_test.py b/test/unit/objects/networking_test.py similarity index 95% rename from test/objects/networking_test.py rename to test/unit/objects/networking_test.py index 51db14e48..c98beea46 100644 --- a/test/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -1,7 +1,7 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4 import ExplicitNullValue -from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range +from linode_api4.objects import Firewall, IPAddress, IPv6Range class NetworkingTest(ClientBaseCase): diff --git a/test/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py similarity index 98% rename from test/objects/nodebalancers_test.py rename to test/unit/objects/nodebalancers_test.py index 822012286..a02054aa4 100644 --- a/test/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -1,11 +1,10 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import ( NodeBalancer, NodeBalancerConfig, NodeBalancerNode, ) -from linode_api4.objects.base import MappedObject class NodeBalancerConfigTest(ClientBaseCase): diff --git a/test/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py similarity index 99% rename from test/objects/object_storage_test.py rename to test/unit/objects/object_storage_test.py index a2c9219e2..59317afa1 100644 --- a/test/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import ( ObjectStorageACL, diff --git a/test/objects/polling_test.py b/test/unit/objects/polling_test.py similarity index 100% rename from test/objects/polling_test.py rename to test/unit/objects/polling_test.py diff --git a/test/objects/profile_test.py b/test/unit/objects/profile_test.py similarity index 98% rename from test/objects/profile_test.py rename to test/unit/objects/profile_test.py index 58fbdf125..cbe8dabd7 100644 --- a/test/objects/profile_test.py +++ b/test/unit/objects/profile_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import Profile, ProfileLogin, SSHKey from linode_api4.objects.profile import TrustedDevice diff --git a/test/objects/region_test.py b/test/unit/objects/region_test.py similarity index 93% rename from test/objects/region_test.py rename to test/unit/objects/region_test.py index 3a2cb62d4..5fd1ee7a3 100644 --- a/test/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,4 +1,4 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import Region diff --git a/test/objects/support_test.py b/test/unit/objects/support_test.py similarity index 95% rename from test/objects/support_test.py rename to test/unit/objects/support_test.py index 50ca0f9b9..0c1ac346a 100644 --- a/test/objects/support_test.py +++ b/test/unit/objects/support_test.py @@ -1,4 +1,4 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import SupportTicket diff --git a/test/objects/tag_test.py b/test/unit/objects/tag_test.py similarity index 93% rename from test/objects/tag_test.py rename to test/unit/objects/tag_test.py index a6c78efbb..137d11deb 100644 --- a/test/objects/tag_test.py +++ b/test/unit/objects/tag_test.py @@ -1,6 +1,6 @@ -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase -from linode_api4.objects import Instance, Tag +from linode_api4.objects import Tag class TagTest(ClientBaseCase): diff --git a/test/objects/volume_test.py b/test/unit/objects/volume_test.py similarity index 98% rename from test/objects/volume_test.py rename to test/unit/objects/volume_test.py index 1dd652eb4..c18ac8d89 100644 --- a/test/objects/volume_test.py +++ b/test/unit/objects/volume_test.py @@ -1,5 +1,5 @@ from datetime import datetime -from test.base import ClientBaseCase +from test.unit.base import ClientBaseCase from linode_api4.objects import Volume diff --git a/test/paginated_list_test.py b/test/unit/paginated_list_test.py similarity index 100% rename from test/paginated_list_test.py rename to test/unit/paginated_list_test.py diff --git a/test/util_test.py b/test/unit/util_test.py similarity index 100% rename from test/util_test.py rename to test/unit/util_test.py diff --git a/tox.ini b/tox.ini index adb2aab2f..e12bf1415 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,6 @@ deps = httpretty commands = python setup.py install - coverage run --source linode_api4 -m pytest test/objects + coverage run --source linode_api4 -m pytest test/unit coverage report pylint linode_api4 From 7e82020341135a5a6612b23ebc9fd85074dd4e1a Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 14 Jun 2023 11:14:53 -0400 Subject: [PATCH 122/379] Update PyPI publish action (#299) --- .github/workflows/publish-pypi.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 0d0dff60b..594a0e617 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -33,6 +33,6 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@37f50c210e3d2f9450da2cd423303d6a14a6e29f # pin@release/v1 + uses: pypa/gh-action-pypi-publish@a56da0b891b3dc519c7ee3284aff1fad93cc8598 # pin@release/v1.8.6 with: - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} From 83e8269ddcc71754ee2708b25dabf7931be94652 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 22 Jun 2023 15:08:53 -0400 Subject: [PATCH 123/379] Remove directly call to setup.py (#300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Calling `setup.py` is deprecated. https://blog.ganssle.io/articles/2021/10/setup-py-deprecated.html --- Makefile | 5 ++--- README.rst | 2 +- docs/guides/getting_started.rst | 2 +- docs/index.rst | 2 +- tox.ini | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 992927606..c7c46267e 100644 --- a/Makefile +++ b/Makefile @@ -20,8 +20,7 @@ clean: @PHONEY: build build: clean - $(PYTHON) setup.py sdist - $(PYTHON) setup.py bdist_wheel + $(PYTHON) -m build --wheel --sdist @PHONEY: release @@ -30,7 +29,7 @@ release: build install: clean - python3 setup.py install + python3 -m pip install . requirements: diff --git a/README.rst b/README.rst index d00012285..b7f68c537 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ Building from Source To build and install this package: - Clone this repository -- ``./setup.py install`` +- ``python3 -m pip install .`` Usage ===== diff --git a/docs/guides/getting_started.rst b/docs/guides/getting_started.rst index 72c671b6e..01b2a6d6c 100644 --- a/docs/guides/getting_started.rst +++ b/docs/guides/getting_started.rst @@ -18,7 +18,7 @@ If you prefer, you can clone the package from github_ and install it from source git clone git@github.com:Linode/linode_api4-python cd linode_api4 - python setup.py install + python -m pip install . Authentication -------------- diff --git a/docs/index.rst b/docs/index.rst index 5fb4ad6a3..828e7e751 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,7 +19,7 @@ To install from source:: git clone https://github.com/linode/linode_api4-python cd linode_api4 - python setup.py install + python -m pip install . For more information, see our :doc:`Getting Started` guide. diff --git a/tox.ini b/tox.ini index e12bf1415..707f91353 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ deps = pylint httpretty commands = - python setup.py install + python -m pip install . coverage run --source linode_api4 -m pytest test/unit coverage report pylint linode_api4 From 1abc7a5fe42ba08686d63fa536c21e672546d466 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:11:04 -0400 Subject: [PATCH 124/379] Add build and certifi installation in the release workflow (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description `build` is necessary to build the package now. --- .github/workflows/publish-pypi.yaml | 2 +- requirements-dev.txt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 594a0e617..01de49508 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -22,7 +22,7 @@ jobs: python-version: '3.x' - name: Install Python deps - run: pip install wheel + run: pip install -U wheel build certifi - name: Install package requirements run: make requirements diff --git a/requirements-dev.txt b/requirements-dev.txt index 5e2d3165f..5d0f7bc51 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,4 +8,5 @@ Sphinx>=6.0.0 sphinx-autobuild>=2021.3.14 sphinxcontrib-fulltoc>=1.2.0 pytest>=7.3.1 -httpretty>=1.1.4 \ No newline at end of file +httpretty>=1.1.4 +build>=0.10.0 From 54c0841ba702707022139829c01c5aa2850456e8 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Mon, 26 Jun 2023 14:29:18 -0400 Subject: [PATCH 125/379] Add `long_description_content_type` to setup function call --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 25c0700b0..9139ee5af 100755 --- a/setup.py +++ b/setup.py @@ -72,6 +72,7 @@ def bake_version(v): description='The official python SDK for Linode API v4', long_description=long_description, + long_description_content_type="text/x-rst", # The project's main homepage. url='https://github.com/linode/linode_api4-python', From 345542070373e86533540476cf5cfd8566ea7c05 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 26 Jun 2023 14:32:28 -0400 Subject: [PATCH 126/379] doc: Improve filtering documentation (#301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change makes various improvements to the documentation relevant to filtering in this repository. This includes correcting broken links, adding an import example, and linking from filter params to the filtering guide. Resolves #217 --- docs/guides/core_concepts.rst | 5 +++-- linode_api4/groups/account.py | 6 ++++++ linode_api4/groups/database.py | 14 ++++++++++++-- linode_api4/groups/domain.py | 2 ++ linode_api4/groups/image.py | 4 +++- linode_api4/groups/linode.py | 10 +++++++++- linode_api4/groups/lke.py | 8 ++++++-- linode_api4/groups/longview.py | 4 ++++ linode_api4/groups/networking.py | 10 ++++++++++ linode_api4/groups/nodebalancer.py | 2 ++ linode_api4/groups/obj.py | 4 ++++ linode_api4/groups/object_storage.py | 8 ++++++++ linode_api4/groups/profile.py | 6 ++++++ linode_api4/groups/region.py | 4 +++- linode_api4/groups/support.py | 2 ++ linode_api4/groups/tag.py | 2 ++ linode_api4/groups/volume.py | 2 ++ linode_api4/objects/filtering.py | 6 ++++++ linode_api4/objects/object_storage.py | 5 +++-- 19 files changed, 93 insertions(+), 11 deletions(-) diff --git a/docs/guides/core_concepts.rst b/docs/guides/core_concepts.rst index 535d8079a..7299c45db 100644 --- a/docs/guides/core_concepts.rst +++ b/docs/guides/core_concepts.rst @@ -49,12 +49,13 @@ certain group. This library implements filtering with a SQLAlchemy-like syntax, where a model's attributes may be used in comparisons to generate filters. For example:: + from linode_api4 import Instance + prod_linodes = client.linode.instances(Instance.group == "production") Filters may be combined using boolean operators similar to SQLAlchemy:: # and_ and or_ can be imported from the linode package to combine filters - from linode_api4 import or_ prod_or_staging = client.linode.instances(or_(Instance.group == "production", Instance.group == "staging")) @@ -66,7 +67,7 @@ Filters may be combined using boolean operators similar to SQLAlchemy:: Filters are generally only applicable for the type of model you are querying, but can be combined to your heart's content. For numeric fields, the standard numeric comparisons are accepted, and work as you'd expect. See -:doc:`Filtering Collections<../linode/objects/filtering>` for full details. +:doc:`Filtering Collections` for full details. Models ------ diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index d630939e0..02c3ab4a2 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -49,6 +49,8 @@ def events(self, *filters): API Documentation: https://www.linode.com/docs/api/account/#events-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of events on the current account matching the given filters. :rtype: PaginatedList of Event @@ -124,6 +126,8 @@ def oauth_clients(self, *filters): API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of OAuth Clients associated with this account. :rtype: PaginatedList of OAuthClient @@ -167,6 +171,8 @@ def users(self, *filters): API Documentation: https://www.linode.com/docs/api/account/#users-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of users on this account. :rtype: PaginatedList of User diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index af4a7a819..8bddd47d0 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -33,7 +33,9 @@ def types(self, *filters): API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of types that match the query. :rtype: PaginatedList of DatabaseType @@ -51,7 +53,9 @@ def engines(self, *filters): API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of types that match the query. :rtype: PaginatedList of DatabaseEngine @@ -65,6 +69,8 @@ def instances(self, *filters): API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of databases that matched the query. :rtype: PaginatedList of Database @@ -78,6 +84,8 @@ def mysql_instances(self, *filters): API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of MySQL databases that matched the query. :rtype: PaginatedList of MySQLDatabase @@ -141,6 +149,8 @@ def postgresql_instances(self, *filters): API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of PostgreSQL databases that matched the query. :rtype: PaginatedList of PostgreSQLDatabase diff --git a/linode_api4/groups/domain.py b/linode_api4/groups/domain.py index 731bc3fb4..c3b11146d 100644 --- a/linode_api4/groups/domain.py +++ b/linode_api4/groups/domain.py @@ -16,6 +16,8 @@ def __call__(self, *filters): API Documentation: https://www.linode.com/docs/api/domains/#domains-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Domains the acting user can access. :rtype: PaginatedList of Domain diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index c1d6f998e..380381422 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -20,7 +20,9 @@ def __call__(self, *filters): API Documentation: https://www.linode.com/docs/api/images/#images-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of available Images. :rtype: PaginatedList of Image diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 3c4112d29..75599f327 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -41,7 +41,9 @@ def types(self, *filters): API documentation: https://www.linode.com/docs/api/linode-types/#types-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of types that match the query. :rtype: PaginatedList of Type @@ -58,6 +60,8 @@ def instances(self, *filters): API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Instances that matched the query. :rtype: PaginatedList of Instance @@ -76,6 +80,8 @@ def stackscripts(self, *filters, **kwargs): API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :param mine_only: If True, returns only private StackScripts :type mine_only: bool @@ -111,6 +117,8 @@ def kernels(self, *filters): API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of available kernels that match the query. :rtype: PaginatedList of Kernel diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index dfd4c6d7a..ac03155a7 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -22,7 +22,9 @@ def versions(self, *filters): API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A Paginated List of kube versions that match the query. :rtype: PaginatedList of KubeVersion @@ -36,7 +38,9 @@ def clusters(self, *filters): https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A Paginated List of LKE clusters that match the query. :rtype: PaginatedList of LKECluster diff --git a/linode_api4/groups/longview.py b/linode_api4/groups/longview.py index 25b980ec7..8caf39962 100644 --- a/linode_api4/groups/longview.py +++ b/linode_api4/groups/longview.py @@ -20,6 +20,8 @@ def clients(self, *filters): API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Longview Clients matching the given filters. :rtype: PaginatedList of LongviewClient @@ -59,6 +61,8 @@ def subscriptions(self, *filters): API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Longview Subscriptions matching the given filters. :rtype: PaginatedList of LongviewSubscription diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 87c84d922..d22486652 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -24,6 +24,8 @@ def firewalls(self, *filters): API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Firewalls the acting user can access. :rtype: PaginatedList of Firewall @@ -98,6 +100,8 @@ def ips(self, *filters): API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of IP addresses on this account. :rtype: PaginatedList of IPAddress @@ -111,6 +115,8 @@ def ipv6_ranges(self, *filters): API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of IPv6 ranges on this account. :rtype: PaginatedList of IPv6Range @@ -124,6 +130,8 @@ def ipv6_pools(self, *filters): API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of IPv6 pools on this account. :rtype: PaginatedList of IPv6Pool @@ -140,6 +148,8 @@ def vlans(self, *filters): API Documentation: https://www.linode.com/docs/api/networking/#vlans-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A List of VLANs on your account. :rtype: PaginatedList of VLAN diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 72bd12ea1..1430ad6a6 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -16,6 +16,8 @@ def __call__(self, *filters): API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of NodeBalancers the acting user can access. :rtype: PaginatedList of NodeBalancers diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py index 78245d246..aae2c6ae2 100644 --- a/linode_api4/groups/obj.py +++ b/linode_api4/groups/obj.py @@ -19,6 +19,8 @@ def clusters(self, *filters): API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Object Storage Clusters that matched the query. :rtype: PaginatedList of ObjectStorageCluster @@ -33,6 +35,8 @@ def keys(self, *filters): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Object Storage Keys that matched the query. :rtype: PaginatedList of ObjectStorageKeys diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index 514e5e3a4..4100b7658 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -27,6 +27,8 @@ def clusters(self, *filters): API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Object Storage Clusters that matched the query. :rtype: PaginatedList of ObjectStorageCluster @@ -41,6 +43,8 @@ def keys(self, *filters): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Object Storage Keys that matched the query. :rtype: PaginatedList of ObjectStorageKeys @@ -174,6 +178,10 @@ def buckets(self, *filters): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-list + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + :returns: A list of Object Storage Buckets that matched the query. :rtype: PaginatedList of ObjectStorageBucket """ diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index 38c2f9695..8618ed3fd 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -227,6 +227,8 @@ def tokens(self, *filters): API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of tokens that matches the query. :rtype: PaginatedList of PersonalAccessToken @@ -276,6 +278,8 @@ def apps(self, *filters): API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Authorized Applications for this user :rtype: PaginatedList of AuthorizedApp @@ -289,6 +293,8 @@ def ssh_keys(self, *filters): API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of SSH Keys for this profile. :rtype: PaginatedList of SSHKey diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index be6440f73..4221c74a8 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -14,7 +14,9 @@ def __call__(self, *filters): API Documentation: https://www.linode.com/docs/api/regions/#regions-list - :param filters: Any number of filters to apply to the query. + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of available Regions. :rtype: PaginatedList of Region diff --git a/linode_api4/groups/support.py b/linode_api4/groups/support.py index faa9547cb..565128b2f 100644 --- a/linode_api4/groups/support.py +++ b/linode_api4/groups/support.py @@ -26,6 +26,8 @@ def tickets(self, *filters): API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of support tickets on this account. :rtype: PaginatedList of SupportTicket diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py index 6276897fa..ebf733159 100644 --- a/linode_api4/groups/tag.py +++ b/linode_api4/groups/tag.py @@ -17,6 +17,8 @@ def __call__(self, *filters): API Documentation: https://www.linode.com/docs/api/domains/#domain-create :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Tags on the account. :rtype: PaginatedList of Tag diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 54a9829a5..032a04c3b 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -16,6 +16,8 @@ def __call__(self, *filters): API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Volumes the acting user can access. :rtype: PaginatedList of Volume diff --git a/linode_api4/objects/filtering.py b/linode_api4/objects/filtering.py index c017a8f1f..3616eb505 100644 --- a/linode_api4/objects/filtering.py +++ b/linode_api4/objects/filtering.py @@ -6,6 +6,12 @@ class of one of its groups, any number of filters may be passed in as boolean comparisons between attributes of the model returned by the collection. +When filtering on API responses for list endpoints, you will first need +to import the corresponding object class. +For example, to filter on instances you must first Import :any:`Instance`:: + + from linode_api4 import Instance + For example, calling :any:`instances` returns a list of :any:`Instance` objects, so we can use properties of :any:`Instance` to filter the results:: diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index d96256c6b..7679653de 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -481,8 +481,9 @@ def buckets_in_cluster(self, *filters): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-in-cluster-list - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. :returns: A list of Object Storage Buckets that in the requested cluster. :rtype: PaginatedList of ObjectStorageBucket From 3d9094686868f75002448a177f77daf841fd8321 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Mon, 26 Jun 2023 14:44:20 -0400 Subject: [PATCH 127/379] Fix README syntax --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b7f68c537..a45ae6259 100644 --- a/README.rst +++ b/README.rst @@ -135,7 +135,7 @@ from the api base url that should be returned, for example:: Integration Tests ------------ +----------------- Integration tests live in the ``test/integration`` directory. Pre-requisite From 5b4cb2cbcb0cc10559c1b1b2e03c5b61067bf101 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 27 Jun 2023 11:25:59 -0400 Subject: [PATCH 128/379] Remove installation of package requirements in pypi publishing workflow (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Installation of package requirements is no longer necessary thanks to the isolated build environment feature of [the build tool](https://pypa-build.readthedocs.io/en/latest/index.html). ## ✔️ How to Test I ran the workflow in my repo without the final publishing step and it succeed. https://github.com/zliang-akamai/linode_api4-python/actions/runs/5382308695 --- .github/workflows/publish-pypi.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 01de49508..c8e47f755 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -24,9 +24,6 @@ jobs: - name: Install Python deps run: pip install -U wheel build certifi - - name: Install package requirements - run: make requirements - - name: Build the package run: make build env: From 5292b796c4c1a8a2977ca6923a1273ae8263ad26 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 27 Jun 2023 11:26:08 -0400 Subject: [PATCH 129/379] Add twine check in lint workflow (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description `twine check` is necessary to ensure the package can be built and validated for publishing on PyPI. ## ✔️ How to Test `make lint` or see the GHA run result. --- .github/workflows/lint.yml | 2 +- .github/workflows/main.yml | 4 ++-- Makefile | 17 +++++++++-------- requirements-dev.txt | 1 + 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63d4fe1cd..6f55a491e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: python-version: '3.x' - name: install dependencies - run: pip3 install -r requirements-dev.txt -r requirements.txt + run: make requirements - name: run linter run: make lint \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ae876b72a..4ea73fb40 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,8 +16,8 @@ jobs: matrix: python-version: ['3.7','3.8','3.9','3.10','3.11'] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Run tests diff --git a/Makefile b/Makefile index c7c46267e..d16bb7a62 100644 --- a/Makefile +++ b/Makefile @@ -27,35 +27,36 @@ build: clean release: build twine upload dist/* - +@PHONEY: install install: clean python3 -m pip install . - +@PHONEY: requirements requirements: pip install -r requirements.txt -r requirements-dev.txt - +@PHONEY: black black: black linode_api4 test - +@PHONEY: isort isort: isort linode_api4 test - +@PHONEY: autoflake autoflake: autoflake linode_api4 test - +@PHONEY: format format: black isort autoflake - -lint: +@PHONEY: lint +lint: build isort --check-only linode_api4 test autoflake --check linode_api4 test black --check --verbose linode_api4 test pylint linode_api4 + twine check dist/* @PHONEY: testint testint: diff --git a/requirements-dev.txt b/requirements-dev.txt index 5d0f7bc51..d80051aa8 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,3 +10,4 @@ sphinxcontrib-fulltoc>=1.2.0 pytest>=7.3.1 httpretty>=1.1.4 build>=0.10.0 +twine>=4.0.2 From 78adef045f1228aa0680492f76a76bd2374d24ab Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 28 Jun 2023 11:07:15 -0400 Subject: [PATCH 130/379] Use parse.quote for URL formatting (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change enforces path escaping for all string formatted URL endpoints. ## ✔️ How to Test ``` pytest test ``` --- linode_api4/groups/object_storage.py | 4 +++- linode_api4/linode_client.py | 6 ++++- linode_api4/objects/linode.py | 11 +++++++-- linode_api4/objects/lke.py | 16 ++++++++++--- linode_api4/objects/networking.py | 4 ++-- linode_api4/objects/nodebalancer.py | 3 ++- linode_api4/objects/object_storage.py | 24 +++++++++++++------ ...orking_ipv6_ranges_2600%3A3c01%3A%3A.json} | 0 8 files changed, 51 insertions(+), 17 deletions(-) rename test/fixtures/{networking_ipv6_ranges_2600:3c01::.json => networking_ipv6_ranges_2600%3A3c01%3A%3A.json} (100%) diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index 4100b7658..df6ba2006 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -1,3 +1,5 @@ +from urllib import parse + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -327,7 +329,7 @@ def object_url_create( result = self.client.post( "/object-storage/buckets/{}/{}/object-url".format( - cluster_id, bucket + parse.quote(str(cluster_id)), parse.quote(str(bucket)) ), data=drop_null_keys(params), ) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 81227e2c2..831e320fb 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -3,6 +3,7 @@ import json import logging from typing import BinaryIO, Tuple +from urllib import parse import pkg_resources import requests @@ -227,7 +228,10 @@ def _api_call( raise ValueError("Method is required for API calls!") if model: - endpoint = endpoint.format(**vars(model)) + endpoint = endpoint.format( + **{k: parse.quote(str(v)) for k, v in vars(model).items()} + ) + url = "{}{}".format(self.base_url, endpoint) headers = { "Authorization": "Bearer {}".format(self.token), diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 82ee96032..79d4c94bf 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -4,6 +4,7 @@ from enum import Enum from os import urandom from random import randint +from urllib import parse from linode_api4 import util from linode_api4.common import load_and_validate_keys @@ -590,7 +591,11 @@ def transfer_year_month(self, year, month): """ result = self._client.get( - "{}/transfer/{}/{}".format(Instance.api_endpoint, year, month), + "{}/transfer/{}/{}".format( + Instance.api_endpoint, + parse.quote(str(year)), + parse.quote(str(month)), + ), model=self, ) @@ -1415,7 +1420,9 @@ def stats_for(self, dt): if not isinstance(dt, datetime): raise TypeError("stats_for requires a datetime object!") return self._client.get( - "{}/stats/{}".format(Instance.api_endpoint, dt.strftime("%Y/%m")), + "{}/stats/{}".format( + Instance.api_endpoint, parse.quote(dt.strftime("%Y/%m")) + ), model=self, ) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index a1ca206e5..42ccfe6fa 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -1,3 +1,5 @@ +from urllib import parse + from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -254,7 +256,10 @@ def node_view(self, nodeId): """ node = self._client.get( - "{}/nodes/{}".format(LKECluster.api_endpoint, nodeId), model=self + "{}/nodes/{}".format( + LKECluster.api_endpoint, parse.quote(str(nodeId)) + ), + model=self, ) return LKENodePoolNode(self._client, node) @@ -270,7 +275,10 @@ def node_delete(self, nodeId): """ self._client.delete( - "{}/nodes/{}".format(LKECluster.api_endpoint, nodeId), model=self + "{}/nodes/{}".format( + LKECluster.api_endpoint, parse.quote(str(nodeId)) + ), + model=self, ) def node_recycle(self, nodeId): @@ -284,7 +292,9 @@ def node_recycle(self, nodeId): """ self._client.post( - "{}/nodes/{}/recycle".format(LKECluster.api_endpoint, nodeId), + "{}/nodes/{}/recycle".format( + LKECluster.api_endpoint, parse.quote(str(nodeId)) + ), model=self, ) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 2d427d3b9..d2b27a88d 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -7,7 +7,7 @@ class IPv6Pool(Base): DEPRECATED """ - api_endpoint = "/networking/ipv6/pools/{}" + api_endpoint = "/networking/ipv6/pools/{range}" id_attribute = "range" properties = { @@ -101,7 +101,7 @@ class VLAN(Base): API Documentation: https://www.linode.com/docs/api/networking/#vlans-list """ - api_endpoint = "/networking/vlans/{}" + api_endpoint = "/networking/vlans/{label}" id_attribute = "label" properties = { diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 2a32204e5..3f9b8e8b6 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,4 +1,5 @@ import os +from urllib import parse from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( @@ -271,7 +272,7 @@ def config_rebuild(self, config_id, nodes, **kwargs): result = self._client.post( "{}/configs/{}/rebuild".format( - NodeBalancer.api_endpoint, config_id + NodeBalancer.api_endpoint, parse.quote(str(config_id)) ), model=self, data=params, diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 7679653de..f1f040677 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,3 +1,5 @@ +from urllib import parse + from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -99,7 +101,7 @@ def access_modify( resp = self._client.post( "/object-storage/buckets/{}/{}/access".format( - self.cluster, self.id + parse.quote(str(self.cluster)), parse.quote(str(self.id)) ), data=drop_null_keys(params), ) @@ -147,7 +149,7 @@ def access_update( resp = self._client.put( "/object-storage/buckets/{}/{}/access".format( - self.cluster, self.id + parse.quote(str(self.cluster)), parse.quote(str(self.id)) ), data=drop_null_keys(params), ) @@ -177,7 +179,9 @@ def ssl_cert_delete(self): """ resp = self._client.delete( - "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id) + "/object-storage/buckets/{}/{}/ssl".format( + parse.quote(str(self.cluster)), parse.quote(str(self.id)) + ) ) if "error" in resp: @@ -206,7 +210,9 @@ def ssl_cert(self): :rtype: MappedObject """ result = self._client.get( - "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id) + "/object-storage/buckets/{}/{}/ssl".format( + parse.quote(str(self.cluster)), parse.quote(str(self.id)) + ) ) if not "ssl" in result: @@ -253,7 +259,9 @@ def ssl_cert_upload(self, certificate, private_key): "private_key": private_key, } result = self._client.post( - "/object-storage/buckets/{}/{}/ssl".format(self.cluster, self.id), + "/object-storage/buckets/{}/{}/ssl".format( + parse.quote(str(self.cluster)), parse.quote(str(self.id)) + ), data=params, ) @@ -325,7 +333,7 @@ def contents( } result = self._client.get( "/object-storage/buckets/{}/{}/object-list".format( - self.cluster, self.id + parse.quote(str(self.cluster)), parse.quote(str(self.id)) ), data=drop_null_keys(params), ) @@ -492,7 +500,9 @@ def buckets_in_cluster(self, *filters): return self._client._get_and_filter( ObjectStorageBucket, *filters, - endpoint="/object-storage/buckets/{}".format(self.id), + endpoint="/object-storage/buckets/{}".format( + parse.quote(str(self.id)) + ), ) diff --git a/test/fixtures/networking_ipv6_ranges_2600:3c01::.json b/test/fixtures/networking_ipv6_ranges_2600%3A3c01%3A%3A.json similarity index 100% rename from test/fixtures/networking_ipv6_ranges_2600:3c01::.json rename to test/fixtures/networking_ipv6_ranges_2600%3A3c01%3A%3A.json From ab2030f9de9975da317d6359c7a046b718f33537 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 4 Jul 2023 07:37:12 -0700 Subject: [PATCH 131/379] Test: Add smoke test suite and workflow (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description https://jira.linode.com/browse/TPT-2054 https://jira.linode.com/browse/TPT-2135 ## ✔️ How to Test make smoketest ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- .github/workflows/e2e-test-pr.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 35 +++++++++++++++++++ Makefile | 4 +++ README.rst | 2 +- .../linode_client/test_linode_client.py | 5 +++ test/integration/linode_client/test_retry.py | 4 ++- test/integration/models/test_account.py | 4 +++ test/integration/models/test_domain.py | 1 + test/integration/models/test_firewall.py | 2 ++ test/integration/models/test_image.py | 1 + test/integration/models/test_linode.py | 1 + test/integration/models/test_lke.py | 1 + test/integration/models/test_longview.py | 3 ++ test/integration/models/test_networking.py | 3 ++ test/integration/models/test_nodebalancer.py | 1 + test/integration/models/test_tag.py | 1 + test/integration/models/test_volume.py | 1 + 17 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/nightly-smoke-tests.yml diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index c08ec571d..af7e1bcaf 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -81,7 +81,7 @@ jobs: - run: make INTEGRATION_TEST_PATH="${{ inputs.test_path }}" testint if: ${{ steps.validate-tests.outputs.match == '' }} env: - LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - uses: actions/github-script@v6 id: update-check-run diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml new file mode 100644 index 000000000..f2934e604 --- /dev/null +++ b/.github/workflows/nightly-smoke-tests.yml @@ -0,0 +1,35 @@ +name: Nightly Smoke Tests + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + smoke_tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: dev + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + + - name: Install Python SDK + run: make install + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run smoke tests + run: | + make smoketest + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} diff --git a/Makefile b/Makefile index d16bb7a62..7636f2192 100644 --- a/Makefile +++ b/Makefile @@ -61,3 +61,7 @@ lint: build @PHONEY: testint testint: python3 -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} + +@PHONEY: smoketest +smoketest: + pytest -m smoke test/integration --disable-warnings \ No newline at end of file diff --git a/README.rst b/README.rst index a45ae6259..7030f59df 100644 --- a/README.rst +++ b/README.rst @@ -140,7 +140,7 @@ Integration tests live in the ``test/integration`` directory. Pre-requisite ^^^^^^^^^^^^^^^^^ -Export Linode API token as `LINODE_CLI_TOKEN` before running integration tests:: +Export Linode API token as `LINODE_TOKEN` before running integration tests:: export LINODE_TOKEN = $(your_token) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 7549ae89a..0df8bc8d7 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -46,6 +46,7 @@ def test_get_account(setup_client_and_linode): assert re.search("^$|[0-9]+", account.tax_id) +@pytest.mark.smoke def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): client = setup_client_and_linode[0] @@ -57,6 +58,7 @@ def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): assert e.status == 400 +@pytest.mark.smoke def test_get_domains(get_client, create_domain): client = get_client domain = create_domain @@ -67,6 +69,7 @@ def test_get_domains(get_client, create_domain): assert domain.domain in dom_list +@pytest.mark.smoke def test_image_create(setup_client_and_linode): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] @@ -165,6 +168,7 @@ def test_create_tag_with_id( assert label in tag_label_list +@pytest.mark.smoke def test_create_tag_with_entities( setup_client_and_linode, create_nodebalancer, create_domain, create_volume ): @@ -223,6 +227,7 @@ def test_create_linode_instance_without_image(get_client): assert res +@pytest.mark.smoke def test_create_linode_instance_with_image(setup_client_and_linode): linode = setup_client_and_linode[1] diff --git a/test/integration/linode_client/test_retry.py b/test/integration/linode_client/test_retry.py index 29fd1d452..a2a8e1b3c 100644 --- a/test/integration/linode_client/test_retry.py +++ b/test/integration/linode_client/test_retry.py @@ -1,6 +1,7 @@ from test.integration.conftest import get_token import httpretty +import pytest from linode_api4 import ApiError, LinodeClient @@ -36,6 +37,7 @@ def get_retry_client(): return client +@pytest.mark.smoke @httpretty.activate def test_get_retry_statuses(): """ @@ -72,7 +74,7 @@ def test_post_retry_statuses(): httpretty.POST, "https://localhost/test", responses=ERROR_RESPONSES ) - get_retry_client.post("/test") + get_retry_client().post("/test") assert len(httpretty.latest_requests()) == 3 diff --git a/test/integration/models/test_account.py b/test/integration/models/test_account.py index 46542545d..308d2425b 100644 --- a/test/integration/models/test_account.py +++ b/test/integration/models/test_account.py @@ -1,6 +1,8 @@ import time from test.integration.helpers import get_test_label +import pytest + from linode_api4.objects import ( Account, AccountSettings, @@ -11,6 +13,7 @@ ) +@pytest.mark.smoke def test_get_account(get_client): client = get_client account = client.account() @@ -56,6 +59,7 @@ def test_get_account_settings(get_client): assert "object_storage" in str(account_settings._raw_json) +@pytest.mark.smoke def test_latest_get_event(get_client): client = get_client diff --git a/test/integration/models/test_domain.py b/test/integration/models/test_domain.py index 2185a53d1..2144fff55 100644 --- a/test/integration/models/test_domain.py +++ b/test/integration/models/test_domain.py @@ -7,6 +7,7 @@ from linode_api4.objects import Domain, DomainRecord +@pytest.mark.smoke def test_get_domain_record(get_client, create_domain): dr = DomainRecord( get_client, create_domain.records.first().id, create_domain.id diff --git a/test/integration/models/test_firewall.py b/test/integration/models/test_firewall.py index 6f0543516..c45812677 100644 --- a/test/integration/models/test_firewall.py +++ b/test/integration/models/test_firewall.py @@ -21,6 +21,7 @@ def create_linode_fw(get_client): linode_instance.delete() +@pytest.mark.smoke def test_get_firewall_rules(get_client, create_firewall): firewall = get_client.load(Firewall, create_firewall.id) rules = firewall.rules @@ -29,6 +30,7 @@ def test_get_firewall_rules(get_client, create_firewall): assert rules.outbound_policy in ["ACCEPT", "DROP"] +@pytest.mark.smoke def test_update_firewall_rules(get_client, create_firewall): firewall = get_client.load(Firewall, create_firewall.id) new_rules = { diff --git a/test/integration/models/test_image.py b/test/integration/models/test_image.py index 6cd97d468..fe828643e 100644 --- a/test/integration/models/test_image.py +++ b/test/integration/models/test_image.py @@ -26,6 +26,7 @@ def image_upload(get_client): delete_instance_with_test_kw(images) +@pytest.mark.smoke def test_get_image(get_client, image_upload): image = get_client.load(Image, image_upload.id) diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index a756ab944..4427c7d5d 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -57,6 +57,7 @@ def create_linode_with_volume_firewall(get_client): volume.delete() +@pytest.mark.smoke @pytest.fixture def create_linode_for_long_running_tests(get_client): client = get_client diff --git a/test/integration/models/test_lke.py b/test/integration/models/test_lke.py index 094e9ae36..11df1cbcc 100644 --- a/test/integration/models/test_lke.py +++ b/test/integration/models/test_lke.py @@ -35,6 +35,7 @@ def get_node_status(cluster: LKECluster, status: str): return node.status == status +@pytest.mark.smoke def test_get_lke_clusters(get_client, create_lke_cluster): cluster = get_client.load(LKECluster, create_lke_cluster.id) diff --git a/test/integration/models/test_longview.py b/test/integration/models/test_longview.py index fcb66c609..a137564d9 100644 --- a/test/integration/models/test_longview.py +++ b/test/integration/models/test_longview.py @@ -1,9 +1,12 @@ import re import time +import pytest + from linode_api4.objects import LongviewClient, LongviewSubscription +@pytest.mark.smoke def test_get_longview_client(get_client, create_longview_client): longview = get_client.load(LongviewClient, create_longview_client.id) diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py index 0f1dfcd87..e90b63ecb 100644 --- a/test/integration/models/test_networking.py +++ b/test/integration/models/test_networking.py @@ -1,6 +1,9 @@ +import pytest + from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range +@pytest.mark.smoke def test_get_networking_rules(get_client, create_firewall): firewall = get_client.load(Firewall, create_firewall.id) diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/test_nodebalancer.py index 6489c0a5e..003fa8f89 100644 --- a/test/integration/models/test_nodebalancer.py +++ b/test/integration/models/test_nodebalancer.py @@ -51,6 +51,7 @@ def test_get_nodebalancer_config(get_client, create_nb_config): ) +@pytest.mark.smoke def test_create_nb_node( get_client, create_nb_config, create_linode_with_private_ip ): diff --git a/test/integration/models/test_tag.py b/test/integration/models/test_tag.py index aa2596633..42b5ec7c5 100644 --- a/test/integration/models/test_tag.py +++ b/test/integration/models/test_tag.py @@ -15,6 +15,7 @@ def create_tag(get_client): tag.delete() +@pytest.mark.smoke def test_get_tag(get_client, create_tag): tag = get_client.load(Tag, create_tag.id) diff --git a/test/integration/models/test_volume.py b/test/integration/models/test_volume.py index 25114178e..92eb67ba3 100644 --- a/test/integration/models/test_volume.py +++ b/test/integration/models/test_volume.py @@ -35,6 +35,7 @@ def get_status(volume: Volume, status: str): return volume.status == status +@pytest.mark.smoke def test_get_volume(get_client, create_volume): volume = get_client.load(Volume, create_volume.id) From 3e7c98f61ced09513e6633c3ce3234da42332cdd Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 6 Jul 2023 12:34:41 -0400 Subject: [PATCH 132/379] new: Add CODEOWNERS (#311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds a CODEOWNERS file and adds the @linode/dx team as a global code owner. --- CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..69cb641ca --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,2 @@ +* @linode/dx + From d231987451fd42287010cd597d04300bd874e070 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 10 Jul 2023 12:35:08 -0400 Subject: [PATCH 133/379] fix: Make the `autoscaler`, `control_plane`, and `k8s_version` fields mutable (#310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change makes the `autoscaler`, `control_plane`, and `k8s_version` fields mutable to reflect their status in the API. ## ✔️ How to Test ``` make testunit ``` --- linode_api4/objects/lke.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 42ccfe6fa..b7edf181a 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -73,7 +73,7 @@ class LKENodePool(DerivedBase): "nodes": Property( volatile=True ), # this is formatted in _populate below - "autoscaler": Property(), + "autoscaler": Property(mutable=True), "tags": Property(mutable=True), } @@ -119,9 +119,9 @@ class LKECluster(Base): "tags": Property(mutable=True), "updated": Property(is_datetime=True), "region": Property(slug_relationship=Region), - "k8s_version": Property(slug_relationship=KubeVersion), + "k8s_version": Property(slug_relationship=KubeVersion, mutable=True), "pools": Property(derived_class=LKENodePool), - "control_plane": Property(), + "control_plane": Property(mutable=True), } @property From 41dbadb5e4e28a808abb399cd26b04150f204f64 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 11 Jul 2023 08:06:35 -0700 Subject: [PATCH 134/379] fix: update filter when creating nb node with private ip (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Fixing one of intermittent failure in smoke tests : test_create_nb_node - Update filter to start from 192.168 since it sometimes picks up public ip if it filters only 192 ## ✔️ How to Test make smoketest ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- test/integration/models/test_nodebalancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/test_nodebalancer.py index 003fa8f89..455b88f1a 100644 --- a/test/integration/models/test_nodebalancer.py +++ b/test/integration/models/test_nodebalancer.py @@ -61,7 +61,7 @@ def test_create_nb_node( create_nb_config.nodebalancer_id, ) linode = create_linode_with_private_ip - address = [a for a in linode.ipv4 if re.search("192.+", a)][0] + address = [a for a in linode.ipv4 if re.search("192.168.+", a)][0] node = config.node_create( "node_test", address + ":80", weight=50, mode="accept" ) From 680c5a27d32cd113e7258bd40df6c35671b6e8c6 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 11 Jul 2023 13:47:01 -0400 Subject: [PATCH 135/379] new: Add support for Metadata-related fields and endpoints (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change adds preemptive support for endpoints and fields introduced as a part of the Linode Metadata Service. ## ✔️ How to Test ``` tox ``` --- linode_api4/groups/image.py | 18 ++++++++++-- linode_api4/groups/linode.py | 39 ++++++++++++++++++++++++++ linode_api4/objects/image.py | 1 + linode_api4/objects/linode.py | 1 + test/fixtures/images.json | 14 +++++---- test/fixtures/images_private_1337.json | 3 +- test/fixtures/images_upload.json | 3 +- test/unit/linode_client_test.py | 1 + test/unit/objects/image_test.py | 31 ++++++++++++++++++++ test/unit/objects/linode_test.py | 39 ++++++++++++++++++++++++++ 10 files changed, 141 insertions(+), 9 deletions(-) diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index 380381422..e19928d7a 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -29,7 +29,7 @@ def __call__(self, *filters): """ return self.client._get_and_filter(Image, *filters) - def create(self, disk, label=None, description=None): + def create(self, disk, label=None, description=None, cloud_init=False): """ Creates a new Image from a disk you own. @@ -42,6 +42,8 @@ def create(self, disk, label=None, description=None): :type label: str :param description: The description for the new Image. :type description: str + :param cloud_init: Whether this Image supports cloud-init. + :type cloud_init: bool :returns: The new Image. :rtype: Image @@ -56,6 +58,9 @@ def create(self, disk, label=None, description=None): if description is not None: params["description"] = description + if cloud_init: + params["cloud_init"] = cloud_init + result = self.client.post("/images", data=params) if not "id" in result: @@ -68,7 +73,11 @@ def create(self, disk, label=None, description=None): return Image(self.client, result["id"], result) def create_upload( - self, label: str, region: str, description: str = None + self, + label: str, + region: str, + description: str = None, + cloud_init: bool = False, ) -> Tuple[Image, str]: """ Creates a new Image and returns the corresponding upload URL. @@ -81,12 +90,17 @@ def create_upload( :type region: str :param description: The description for the new Image. :type description: str + :param cloud_init: Whether this Image supports cloud-init. + :type cloud_init: bool :returns: A tuple containing the new image and the image upload URL. :rtype: (Image, str) """ params = {"label": label, "region": region, "description": description} + if cloud_init: + params["cloud_init"] = cloud_init + result = self.client.post("/images/upload", data=drop_null_keys(params)) if "image" not in result: diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 75599f327..ae575ed3c 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,3 +1,4 @@ +import base64 import os from linode_api4 import Profile @@ -245,6 +246,10 @@ def instance_create( :param private_ip: Whether the new Instance should have private networking enabled and assigned a private IPv4 address. :type private_ip: bool + :param metadata: Metadata-related fields to use when creating the new Instance. + The contents of this field can be built using the + :any:`build_instance_metadata` method. + :type metadata: dict :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -301,6 +306,40 @@ def instance_create( return l return l, ret_pass + @staticmethod + def build_instance_metadata(user_data=None, encode_user_data=True): + """ + Builds the contents of the ``metadata`` field to be passed into + the :any:`instance_create` method. This helper can also be used + when cloning and rebuilding Instances. + **Creating an Instance with User Data**:: + new_linode, password = client.linode.instance_create( + "g6-standard-2", + "us-east", + image="linode/ubuntu22.04", + metadata=client.linode.build_instance_metadata(user_data="myuserdata") + ) + :param user_data: User-defined data to provide to the Linode Instance through + the Metadata service. + :type user_data: str + :param encode_user_data: If true, the provided user_data field will be automatically + encoded to a valid base64 string. This field should only + be set to false if the user_data param is already base64-encoded. + :type encode_user_data: bool + :returns: The built ``metadata`` structure. + :rtype: dict + """ + result = {} + + if user_data is not None: + result["user_data"] = ( + base64.b64encode(user_data.encode()).decode() + if encode_user_data + else user_data + ) + + return result + def stackscript_create( self, label, script, images, desc=None, public=False, **kwargs ): diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 381bb00e6..606743ce0 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -25,4 +25,5 @@ class Image(Base): "vendor": Property(), "size": Property(), "deprecated": Property(), + "capabilities": Property(), } diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 79d4c94bf..3bc402b0d 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -412,6 +412,7 @@ class Instance(Base): "tags": Property(mutable=True), "host_uuid": Property(), "watchdog_enabled": Property(mutable=True), + "has_user_data": Property(), } @property diff --git a/test/fixtures/images.json b/test/fixtures/images.json index 69e5c3380..c33141527 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -17,7 +17,8 @@ "vendor": "Debian", "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", - "updated": "2020-07-01T04:00:00" + "updated": "2020-07-01T04:00:00", + "capabilities": [] }, { "created": "2017-01-01T00:01:01", @@ -33,7 +34,8 @@ "vendor": "Ubuntu", "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", - "updated": "2020-07-01T04:00:00" + "updated": "2020-07-01T04:00:00", + "capabilities": [] }, { "created": "2017-01-01T00:01:01", @@ -49,7 +51,8 @@ "vendor": "Fedora", "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", - "updated": "2020-07-01T04:00:00" + "updated": "2020-07-01T04:00:00", + "capabilities": [] }, { "created": "2017-08-20T14:01:01", @@ -65,7 +68,8 @@ "vendor": null, "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", - "updated": "2020-07-01T04:00:00" + "updated": "2020-07-01T04:00:00", + "capabilities": ["cloud-init"] } ] -} +} \ No newline at end of file diff --git a/test/fixtures/images_private_1337.json b/test/fixtures/images_private_1337.json index f8864f4a1..b4deae196 100644 --- a/test/fixtures/images_private_1337.json +++ b/test/fixtures/images_private_1337.json @@ -12,5 +12,6 @@ "status": "available", "type": "manual", "updated": "2021-08-14T22:44:02", - "vendor": "Debian" + "vendor": "Debian", + "capabilities": ["cloud-init"] } \ No newline at end of file diff --git a/test/fixtures/images_upload.json b/test/fixtures/images_upload.json index cafda237b..60f726464 100644 --- a/test/fixtures/images_upload.json +++ b/test/fixtures/images_upload.json @@ -13,7 +13,8 @@ "status": "available", "type": "manual", "updated": "2021-08-14T22:44:02", - "vendor": "Debian" + "vendor": "Debian", + "capabilities": ["cloud-init"] }, "upload_to": "https://linode.com/" } \ No newline at end of file diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index b04033447..8d1b1c69b 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -129,6 +129,7 @@ def test_image_create(self): self.assertIsNotNone(i) self.assertEqual(i.id, "private/123") + self.assertEqual(i.capabilities[0], "cloud-init") self.assertEqual(m.call_url, "/images") diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index 2f3ac610e..983192e69 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -77,6 +77,7 @@ def test_image_create_upload(self): self.assertEqual(image.id, "private/1337") self.assertEqual(image.label, "Realest Image Upload") self.assertEqual(image.description, "very real image upload.") + self.assertEqual(image.capabilities[0], "cloud-init") self.assertEqual(url, "https://linode.com/") @@ -100,3 +101,33 @@ def put_mock(url: str, data: BinaryIO = None, **kwargs): self.assertEqual(image.id, "private/1337") self.assertEqual(image.label, "Realest Image Upload") self.assertEqual(image.description, "very real image upload.") + + def test_image_create_cloud_init(self): + """ + Test that an image can be created successfully with cloud-init. + """ + + with self.mock_post("images/private/123") as m: + self.client.images.create( + "Test Image", + "us-southeast", + description="very real image upload.", + cloud_init=True, + ) + + self.assertTrue(m.call_data["cloud_init"]) + + def test_image_create_upload_cloud_init(self): + """ + Test that an image upload URL can be created successfully with cloud-init. + """ + + with self.mock_post("images/upload") as m: + self.client.images.create_upload( + "Test Image", + "us-southeast", + description="very real image upload.", + cloud_init=True, + ) + + self.assertTrue(m.call_data["cloud_init"]) diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index d336478a7..07b08c188 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -387,6 +387,45 @@ def test_create_disk(self): assert disk.id == 12345 + def test_instance_create_with_user_data(self): + """ + Tests that the metadata field is populated on Linode create. + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-southeast", + metadata=self.client.linode.build_instance_metadata( + user_data="cool" + ), + ) + + self.assertEqual( + m.call_data, + { + "region": "us-southeast", + "type": "g6-nanode-1", + "metadata": {"user_data": "Y29vbA=="}, + }, + ) + + def test_build_instance_metadata(self): + """ + Tests that the metadata field is built correctly. + """ + self.assertEqual( + self.client.linode.build_instance_metadata(user_data="cool"), + {"user_data": "Y29vbA=="}, + ) + + self.assertEqual( + self.client.linode.build_instance_metadata( + user_data="cool", encode_user_data=False + ), + {"user_data": "cool"}, + ) + class DiskTest(ClientBaseCase): """ From 620091f84d772e0e2bb06c11f0d0370530bd863f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 14 Aug 2023 13:58:12 -0400 Subject: [PATCH 136/379] Add LinodeClient type annotation in Group class (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Better dev experience with an IDE or text editor with this typing. --- linode_api4/groups/group.py | 10 +++++++++- linode_api4/linode_client.py | 21 +++++++++++++++++++-- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py index 1ca41627a..c591b7fda 100644 --- a/linode_api4/groups/group.py +++ b/linode_api4/groups/group.py @@ -1,3 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from linode_api4 import LinodeClient + + class Group: - def __init__(self, client: "LinodeClient"): + def __init__(self, client: LinodeClient): self.client = client diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 831e320fb..5b2cc8561 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -10,8 +10,25 @@ from requests.adapters import HTTPAdapter, Retry from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.groups import * -from linode_api4.objects import * +from linode_api4.groups import ( + AccountGroup, + DatabaseGroup, + DomainGroup, + ImageGroup, + LinodeGroup, + LKEGroup, + LongviewGroup, + NetworkingGroup, + NodeBalancerGroup, + ObjectStorageGroup, + PollingGroup, + ProfileGroup, + RegionGroup, + SupportGroup, + TagGroup, + VolumeGroup, +) +from linode_api4.objects import Image, and_ from linode_api4.objects.filtering import Filter from .common import SSH_KEY_TYPES, load_and_validate_keys diff --git a/pyproject.toml b/pyproject.toml index d04b49d84..ccb0ec5f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ line-length = 80 target-version = ["py37", "py38", "py39", "py310", "py311"] [tool.autoflake] -expand-star-imports = false +expand-star-imports = true ignore-init-module-imports = true ignore-pass-after-docstring = true in-place = true From 12c60ef517aa5c875addafa388c76755b5c2c4c6 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 15 Aug 2023 11:44:33 -0400 Subject: [PATCH 137/379] fix: `ip_addresses_share` to remove shared IPs (#316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Fix an issue in `ip_addresses_share` to allow it unshare IPs. In the API doc, entering an empty IP array is allowed and it means removing the shared IPs from the Linode. In the previous implementation, we always looked for `ips[0]` when building the request param. It brought the index out of range error when an empty IP array is given. ## ✔️ How to Test `tox` --- linode_api4/groups/networking.py | 20 +++++++++--- test/fixtures/networking_ips_share.json | 1 + test/integration/models/test_networking.py | 36 ++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/networking_ips_share.json diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index d22486652..a226874ef 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -284,22 +284,32 @@ def ips_share(self, linode, *ips): def ip_addresses_share(self, ips, linode): """ - Configure shared IPs. P sharing allows IP address reassignment + Configure shared IPs. IP sharing allows IP address reassignment (also referred to as IP failover) from one Linode to another if the primary Linode becomes unresponsive. This means that requests to the primary Linode’s IP address can be automatically rerouted to secondary Linodes at the configured shared IP addresses. + API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-share + :param linode: The id of the Instance or the Instance to share the IPAddresses with. This Instance will be able to bring up the given addresses. :type: linode: int or Instance - :param ips: Any number of IPAddresses to share to the Instance. + :param ips: Any number of IPAddresses to share to the Instance. Enter an empty array to + remove all shared IP addresses. :type ips: str or IPAddress """ + shared_ips = [] + for ip in ips: + if isinstance(ip, str): + shared_ips.append(ip) + elif isinstance(ip, IPAddress): + shared_ips.append(ip.address) + else: + shared_ips.append(str(ip)) # and hope that works + params = { - "ips": ips - if not isinstance(ips[0], IPAddress) - else [ip.address for ip in ips], + "ips": shared_ips, "linode_id": linode if not isinstance(linode, Instance) else linode.id, diff --git a/test/fixtures/networking_ips_share.json b/test/fixtures/networking_ips_share.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/networking_ips_share.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py index e90b63ecb..d3b01e0bd 100644 --- a/test/integration/models/test_networking.py +++ b/test/integration/models/test_networking.py @@ -13,3 +13,39 @@ def test_get_networking_rules(get_client, create_firewall): assert "inbound_policy" in str(rules) assert "outbound" in str(rules) assert "outbound_policy" in str(rules) + + +@pytest.mark.smoke +def test_ip_addresses_share(self): + """ + Test that you can share IP addresses with Linode. + """ + ip_share_url = "/networking/ips/share" + ips = ["127.0.0.1"] + linode_id = 12345 + with self.mock_post(ip_share_url) as m: + result = self.client.networking.ip_addresses_share(ips, linode_id) + + self.assertIsNotNone(result) + self.assertEqual(m.call_url, ip_share_url) + self.assertEqual( + m.call_data, + { + "ips": ips, + "linode": linode_id, + }, + ) + + # Test that entering an empty IP array is allowed. + with self.mock_post(ip_share_url) as m: + result = self.client.networking.ip_addresses_share([], linode_id) + + self.assertIsNotNone(result) + self.assertEqual(m.call_url, ip_share_url) + self.assertEqual( + m.call_data, + { + "ips": [], + "linode": linode_id, + }, + ) From 6ff915b934265211d23f058e3e476d42aade7726 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 21 Aug 2023 10:24:30 -0400 Subject: [PATCH 138/379] fix: Add missing fields to `IPv6Range` (#318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description [IPv6 range view](https://www.linode.com/docs/api/networking/#ipv6-range-view__responses) and [IPv6 range list](https://www.linode.com/docs/api/networking/#ipv6-ranges-list__responses) actually share different response structures. The current `IPv6Range` object has been built based on the structure of IPv6 range list, so fields `linodes` and `is_bgp` are missing. To quickly fix it, I think simply adding these two fields to the `IPv6Range` object properties makes sense without introducing any breaking change. Otherwise we may consider create two separated objects for returning a single object and list, which is probably confusing for customers to use and more complicated for us to maintain. Any different idea is appreciated. ## ✔️ How to Test `tox` --- linode_api4/objects/networking.py | 2 ++ test/unit/objects/networking_test.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index d2b27a88d..433c318f6 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -31,6 +31,8 @@ class IPv6Range(Base): "region": Property(slug_relationship=Region), "prefix": Property(), "route_target": Property(), + "linodes": Property(), + "is_bgp": Property(), } diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index c98beea46..7d32ae68a 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -20,6 +20,8 @@ def test_get_ipv6_range(self): self.assertEqual(ipv6Range.range, "2600:3c01::") self.assertEqual(ipv6Range.prefix, 64) self.assertEqual(ipv6Range.region.id, "us-east") + self.assertEqual(ipv6Range.linodes[0], 123) + self.assertEqual(ipv6Range.is_bgp, False) ranges = self.client.networking.ipv6_ranges() From ddf36d6f4f3e95de39316ca29eb01ca5c9cc588b Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Fri, 25 Aug 2023 14:07:36 -0400 Subject: [PATCH 139/379] new: Add `beta` object and group (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Implement self serve beta in python SDK. Create `beta` object and group to support existing beta program endpoints: ``` GET /betas/:id GET /betas ``` ## ✔️ How to Test `tox` --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/beta.py | 24 ++++++++++++++++++++++++ linode_api4/linode_client.py | 4 ++++ linode_api4/objects/__init__.py | 1 + linode_api4/objects/beta.py | 22 ++++++++++++++++++++++ test/fixtures/betas.json | 24 ++++++++++++++++++++++++ test/fixtures/betas_active.json | 9 +++++++++ test/unit/linode_client_test.py | 21 +++++++++++++++++++++ test/unit/objects/beta_test.py | 30 ++++++++++++++++++++++++++++++ test/unit/objects/linode_test.py | 6 +++--- 10 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 linode_api4/groups/beta.py create mode 100644 linode_api4/objects/beta.py create mode 100644 test/fixtures/betas.json create mode 100644 test/fixtures/betas_active.json create mode 100644 test/unit/objects/beta_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index e3d7658fe..f41f8cb9b 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -2,6 +2,7 @@ from .group import * # isort: skip from .account import * +from .beta import * from .database import * from .domain import * from .image import * diff --git a/linode_api4/groups/beta.py b/linode_api4/groups/beta.py new file mode 100644 index 000000000..18095dc03 --- /dev/null +++ b/linode_api4/groups/beta.py @@ -0,0 +1,24 @@ +from linode_api4.groups import Group +from linode_api4.objects import BetaProgram + + +class BetaProgramGroup(Group): + """ + This group encapsulates all endpoints under /betas, including viewing + available active beta programs. + """ + + def betas(self, *filters): + """ + Returns a list of available active Beta Programs. + + API Documentation: TBD + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Beta Programs that matched the query. + :rtype: PaginatedList of BetaProgram + """ + return self.client._get_and_filter(BetaProgram, *filters) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 5b2cc8561..74e005ff5 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -12,6 +12,7 @@ from linode_api4.errors import ApiError, UnexpectedResponseError from linode_api4.groups import ( AccountGroup, + BetaProgramGroup, DatabaseGroup, DomainGroup, ImageGroup, @@ -188,6 +189,9 @@ def __init__( #: Access methods related to Event polling - See :any:`PollingGroup` for more information. self.polling = PollingGroup(self) + #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. + self.beta = BetaProgramGroup(self) + @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 256e0193d..cd02fcc01 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -17,3 +17,4 @@ from .object_storage import * from .lke import * from .database import * +from .beta import * diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py new file mode 100644 index 000000000..3124f4f15 --- /dev/null +++ b/linode_api4/objects/beta.py @@ -0,0 +1,22 @@ +from linode_api4.objects import Base, Property + + +class BetaProgram(Base): + """ + Beta program is a new product or service that's not generally available to all customers. + User with permissions can enroll into a beta program and access the functionalities. + + API Documentation: TBD + """ + + api_endpoint = "/betas/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "description": Property(), + "started": Property(is_datetime=True), + "ended": Property(is_datetime=True), + "greenlight_only": Property(), + "more_info": Property(), + } diff --git a/test/fixtures/betas.json b/test/fixtures/betas.json new file mode 100644 index 000000000..8af261307 --- /dev/null +++ b/test/fixtures/betas.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "id": "active_closed", + "label": "active closed beta", + "description": "An active closed beta", + "started": "2023-07-19T15:23:43", + "ended": null, + "greenlight_only": true, + "more_info": "a link with even more info" + }, + { + "id": "limited", + "label": "limited beta", + "description": "An active limited beta", + "started": "2023-07-19T15:23:43", + "ended": null, "greenlight_only": false, + "more_info": "a link with even more info" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/betas_active.json b/test/fixtures/betas_active.json new file mode 100644 index 000000000..ce9db7c14 --- /dev/null +++ b/test/fixtures/betas_active.json @@ -0,0 +1,9 @@ +{ + "id": "active", + "label": "active closed beta", + "description": "An active closed beta", + "started": "2018-01-02T03:04:05", + "ended": null, + "greenlight_only": true, + "more_info": "a link with even more info" +} \ No newline at end of file diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 8d1b1c69b..512449aa3 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -412,6 +412,27 @@ def test_payments(self): self.assertEqual(payment.usd, 1000) +class BetaProgramGroupTest(ClientBaseCase): + """ + Tests methods of the BetaProgramGroup + """ + + def test_betas(self): + """ + Test that available beta programs can be retrieved + """ + betas = self.client.beta.betas() + + self.assertEqual(len(betas), 2) + beta = betas[0] + self.assertEqual(beta.id, "active_closed") + self.assertEqual(beta.label, "active closed beta") + self.assertEqual(beta.started, datetime(2023, 7, 19, 15, 23, 43)) + self.assertEqual(beta.ended, None) + self.assertEqual(beta.greenlight_only, True) + self.assertEqual(beta.more_info, "a link with even more info") + + class LinodeGroupTest(ClientBaseCase): """ Tests methods of the LinodeGroup diff --git a/test/unit/objects/beta_test.py b/test/unit/objects/beta_test.py new file mode 100644 index 000000000..98c6437c1 --- /dev/null +++ b/test/unit/objects/beta_test.py @@ -0,0 +1,30 @@ +from datetime import datetime +from test.unit.base import ClientBaseCase + +from linode_api4.objects import BetaProgram + + +class BetaProgramTest(ClientBaseCase): + """ + Test the methods of the Beta Program. + """ + + def test_beta_program_api_get(self): + beta_id = "active" + beta_program_api_get_url = "/betas/{}".format(beta_id) + + with self.mock_get(beta_program_api_get_url) as m: + beta_program = BetaProgram(self.client, beta_id) + self.assertEqual(beta_program.id, beta_id) + self.assertEqual(beta_program.label, "active closed beta") + self.assertEqual(beta_program.description, "An active closed beta") + self.assertEqual( + beta_program.started, datetime(2018, 1, 2, 3, 4, 5) + ) + self.assertEqual(beta_program.ended, None) + self.assertEqual(beta_program.greenlight_only, True) + self.assertEqual( + beta_program.more_info, "a link with even more info" + ) + + self.assertEqual(m.call_url, beta_program_api_get_url) diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 07b08c188..1f75f8c1a 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -259,7 +259,7 @@ def test_firewalls(self): with self.mock_get("/linode/instances/123/firewalls") as m: result = linode.firewalls() self.assertEqual(m.call_url, "/linode/instances/123/firewalls") - self.assertEquals(len(result), 1) + self.assertEqual(len(result), 1) def test_volumes(self): """ @@ -270,7 +270,7 @@ def test_volumes(self): with self.mock_get("/linode/instances/123/volumes") as m: result = linode.volumes() self.assertEqual(m.call_url, "/linode/instances/123/volumes") - self.assertEquals(len(result), 1) + self.assertEqual(len(result), 1) def test_nodebalancers(self): """ @@ -281,7 +281,7 @@ def test_nodebalancers(self): with self.mock_get("/linode/instances/123/nodebalancers") as m: result = linode.nodebalancers() self.assertEqual(m.call_url, "/linode/instances/123/nodebalancers") - self.assertEquals(len(result), 1) + self.assertEqual(len(result), 1) def test_transfer_year_month(self): """ From 24dae2309d66efcabedae62ad14c04d8e47edfd9 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 28 Aug 2023 12:57:01 -0400 Subject: [PATCH 140/379] new: Add `AccountBetaProgram` to support self serve beta (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description `AccountBetaProgram` object returns information about a beta program an account enrolled in, including the enrollment information. Customer can also check the list of all enrolled betas and join an active beta program under the account group. Endpoints implemented: ``` GET /account/betas GET /account/betas/id POST /account/betas ``` ## ✔️ How to Test `tox` --- linode_api4/groups/account.py | 33 ++++++++++++++++++++ linode_api4/objects/account.py | 17 +++++++++++ test/fixtures/account_betas.json | 15 ++++++++++ test/fixtures/account_betas_cool.json | 8 +++++ test/unit/linode_client_test.py | 43 +++++++++++++++++++++++++++ test/unit/objects/account_test.py | 20 +++++++++++++ 6 files changed, 136 insertions(+) create mode 100644 test/fixtures/account_betas.json create mode 100644 test/fixtures/account_betas_cool.json diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 02c3ab4a2..ac615867d 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -2,7 +2,9 @@ from linode_api4.groups import Group from linode_api4.objects import ( Account, + AccountBetaProgram, AccountSettings, + Base, Event, Invoice, Login, @@ -448,3 +450,34 @@ def user_create(self, email, username, restricted=True): u = User(self.client, result["username"], result) return u + + def enrolled_betas(self, *filters): + """ + Returns a list of all Beta Programs an account is enrolled in. + + API doc: TBD + + :returns: a list of Beta Programs. + :rtype: PaginatedList of AccountBetaProgram + """ + return self.client._get_and_filter(AccountBetaProgram, *filters) + + def join_beta_program(self, beta): + """ + Enrolls an account into a beta program. + + API doc: TBD + + :param beta: The object or id of a beta program to join. + :type beta: BetaProgram or str + + :returns: A boolean indicating whether the account joined a beta program successfully. + :rtype: bool + """ + + self.client.post( + "/account/betas", + data={"id": beta.id if issubclass(type(beta), Base) else beta}, + ) + + return True diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 4db05e802..264c6ff60 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -637,3 +637,20 @@ def save(self): self._populate(result) return True + + +class AccountBetaProgram(Base): + """ + The details and enrollment information of a Beta program that an account is enrolled in. + """ + + api_endpoint = "/account/betas/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "description": Property(), + "started": Property(is_datetime=True), + "ended": Property(is_datetime=True), + "enrolled": Property(is_datetime=True), + } diff --git a/test/fixtures/account_betas.json b/test/fixtures/account_betas.json new file mode 100644 index 000000000..0ebb3858e --- /dev/null +++ b/test/fixtures/account_betas.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "id": "cool", + "label": "\r\n\r\nRepellat consequatur sunt qui.", + "enrolled": "2018-01-02T03:04:05", + "description": "Repellat consequatur sunt qui. Fugit eligendi ipsa et assumenda ea aspernatur esse. A itaque iste distinctio qui voluptas eum enim ipsa.", + "started": "2018-01-02T03:04:05", + "ended": "2018-01-02T03:04:05" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/account_betas_cool.json b/test/fixtures/account_betas_cool.json new file mode 100644 index 000000000..39f0310b3 --- /dev/null +++ b/test/fixtures/account_betas_cool.json @@ -0,0 +1,8 @@ +{ + "id": "cool", + "label": "\r\n\r\nRepellat consequatur sunt qui.", + "enrolled": "2018-01-02T03:04:05", + "description": "Repellat consequatur sunt qui. Fugit eligendi ipsa et assumenda ea aspernatur esse. A itaque iste distinctio qui voluptas eum enim ipsa.", + "started": "2018-01-02T03:04:05", + "ended": "2018-01-02T03:04:05" +} \ No newline at end of file diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 512449aa3..86dbfd3e4 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -2,6 +2,7 @@ from test.unit.base import ClientBaseCase from linode_api4 import LongviewSubscription +from linode_api4.objects.beta import BetaProgram from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress from linode_api4.objects.object_storage import ( @@ -411,6 +412,48 @@ def test_payments(self): self.assertEqual(payment.date, datetime(2015, 1, 1, 5, 1, 2)) self.assertEqual(payment.usd, 1000) + def test_enrolled_betas(self): + """ + Tests that enrolled beta programs can be retrieved + """ + enrolled_betas = self.client.account.enrolled_betas() + + self.assertEqual(len(enrolled_betas), 1) + beta = enrolled_betas[0] + + self.assertEqual(beta.id, "cool") + self.assertEqual(beta.enrolled, datetime(2018, 1, 2, 3, 4, 5)) + self.assertEqual(beta.started, datetime(2018, 1, 2, 3, 4, 5)) + self.assertEqual(beta.ended, datetime(2018, 1, 2, 3, 4, 5)) + + def test_join_beta_program(self): + """ + Tests that user can join a beta program + """ + join_beta_url = "/account/betas" + with self.mock_post({}) as m: + self.client.account.join_beta_program("cool_beta") + self.assertEqual( + m.call_data, + { + "id": "cool_beta", + }, + ) + self.assertEqual(m.call_url, join_beta_url) + + # Test that user can join a beta program with an BetaProgram object + with self.mock_post({}) as m: + self.client.account.join_beta_program( + BetaProgram(self.client, "cool_beta") + ) + self.assertEqual( + m.call_data, + { + "id": "cool_beta", + }, + ) + self.assertEqual(m.call_url, join_beta_url) + class BetaProgramGroupTest(ClientBaseCase): """ diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 09aba9e7f..f58aa677d 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -3,6 +3,7 @@ from linode_api4.objects import ( Account, + AccountBetaProgram, AccountSettings, Database, Domain, @@ -240,3 +241,22 @@ def test_service_transfer_accept(self): self.assertEqual( m.call_url, "/account/service-transfers/12345/accept" ) + + +class AccountBetaProgramTest(ClientBaseCase): + """ + Tests methods of the AccountBetaProgram + """ + + def test_account_beta_program_api_get(self): + beta_id = "cool" + account_beta_url = "/account/betas/{}".format(beta_id) + + with self.mock_get(account_beta_url) as m: + beta = AccountBetaProgram(self.client, beta_id) + self.assertEqual(beta.id, beta_id) + self.assertEqual(beta.enrolled, datetime(2018, 1, 2, 3, 4, 5)) + self.assertEqual(beta.started, datetime(2018, 1, 2, 3, 4, 5)) + self.assertEqual(beta.ended, datetime(2018, 1, 2, 3, 4, 5)) + + self.assertEqual(m.call_url, account_beta_url) From 7415f28e1e1ca1dc6de9f2c2fa16d5f898413355 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:58:36 -0400 Subject: [PATCH 141/379] Restrict beta param type for `join_beta_program` (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description A small improvement to better restrict the `beta` param type to be one of the `Union[str, BetaProgram]` when calling `join_beta_program()`. ## ✔️ How to Test `tox` --- linode_api4/groups/account.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index ac615867d..4eeadcc11 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -1,10 +1,12 @@ +from typing import Union + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( Account, AccountBetaProgram, AccountSettings, - Base, + BetaProgram, Event, Invoice, Login, @@ -462,7 +464,7 @@ def enrolled_betas(self, *filters): """ return self.client._get_and_filter(AccountBetaProgram, *filters) - def join_beta_program(self, beta): + def join_beta_program(self, beta: Union[str, BetaProgram]): """ Enrolls an account into a beta program. @@ -477,7 +479,7 @@ def join_beta_program(self, beta): self.client.post( "/account/betas", - data={"id": beta.id if issubclass(type(beta), Base) else beta}, + data={"id": beta.id if isinstance(beta, BetaProgram) else beta}, ) return True From 5297b74262b8de4d9303e0e814380c046aaeff96 Mon Sep 17 00:00:00 2001 From: Ania Misiorek Date: Wed, 30 Aug 2023 17:12:23 -0400 Subject: [PATCH 142/379] implementation + testing --- Makefile | 2 +- linode_api4/objects/linode.py | 1 + test/fixtures/account_transfer.json | 14 ++++ test/fixtures/linode_types.json | 114 +++++++++++++++++++++++++--- test/integration/conftest.py | 2 +- test/unit/linode_client_test.py | 16 ++++ test/unit/objects/linode_test.py | 3 + 7 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/account_transfer.json diff --git a/Makefile b/Makefile index 7636f2192..4cd378b1c 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ release: build twine upload dist/* @PHONEY: install -install: clean +install: clean requirements python3 -m pip install . @PHONEY: requirements diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 3bc402b0d..d928b31b6 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -232,6 +232,7 @@ class Type(Base): "label": Property(), "network_out": Property(), "price": Property(), + "region_prices": Property(), "addons": Property(), "memory": Property(), "transfer": Property(), diff --git a/test/fixtures/account_transfer.json b/test/fixtures/account_transfer.json new file mode 100644 index 000000000..ce4658a6a --- /dev/null +++ b/test/fixtures/account_transfer.json @@ -0,0 +1,14 @@ +{ + "quota": 471, + "used": 737373, + "billable": 0, + + "region_transfers": [ + { + "id": "ap-west", + "used": 1, + "quota": 5010, + "billable": 0 + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_types.json b/test/fixtures/linode_types.json index b270da778..c864082e8 100644 --- a/test/fixtures/linode_types.json +++ b/test/fixtures/linode_types.json @@ -1,8 +1,8 @@ { -"results": 4, -"pages": 1, -"page": 1, -"data": [ + "results": 4, + "pages": 1, + "page": 1, + "data": [ { "disk": 20480, "memory": 1024, @@ -12,7 +12,19 @@ "price": { "hourly": 0.003, "monthly": 2 - } + }, + "region_prices": [ + { + "id": "ap-west", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ] } }, "class": "nanode", @@ -25,6 +37,18 @@ "hourly": 0.0075, "monthly": 5 }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ], "successor": null }, { @@ -36,7 +60,19 @@ "price": { "hourly": 0.008, "monthly": 5 - } + }, + "region_prices": [ + { + "id": "ap-west", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ] } }, "class": "highmem", @@ -49,6 +85,18 @@ "hourly": 0.09, "monthly": 60 }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ], "successor": null }, { @@ -60,7 +108,19 @@ "price": { "hourly": 0.004, "monthly": 2.5 - } + }, + "region_prices": [ + { + "id": "ap-west", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ] } }, "class": "standard", @@ -73,6 +133,18 @@ "hourly": 0.015, "monthly": 10 }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ], "successor": null }, { @@ -84,7 +156,19 @@ "price": { "hourly": 0.008, "monthly": 5 - } + }, + "region_prices": [ + { + "id": "ap-west", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ] } }, "class": "gpu", @@ -97,7 +181,19 @@ "hourly": 0.03, "monthly": 20 }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ], "successor": null } ] -} +} \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index b3fa15fb2..69248b57a 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -3,7 +3,7 @@ import pytest -from linode_api4.linode_client import LinodeClient, LongviewSubscription +from linode_api4.linode_client import LinodeClient ENV_TOKEN_NAME = "LINODE_TOKEN" RUN_LONG_TESTS = "RUN_LONG_TESTS" diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 512449aa3..acc689bdb 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -411,6 +411,22 @@ def test_payments(self): self.assertEqual(payment.date, datetime(2015, 1, 1, 5, 1, 2)) self.assertEqual(payment.usd, 1000) + def test_account_transfer(self): + """ + Tests that payments can be retrieved + """ + transfer = self.client.account.transfer() + + self.assertEqual(transfer.quota, 471) + self.assertEqual(transfer.used, 737373) + self.assertEqual(transfer.billable, 0) + + self.assertEqual(len(transfer.region_transfers), 1) + self.assertEqual(transfer.region_transfers[0].id, "ap-west") + self.assertEqual(transfer.region_transfers[0].used, 1) + self.assertEqual(transfer.region_transfers[0].quota, 5010) + self.assertEqual(transfer.region_transfers[0].billable, 0) + class BetaProgramGroupTest(ClientBaseCase): """ diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 1f75f8c1a..2aa280fef 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -533,6 +533,8 @@ def test_get_types(self): self.assertIsNotNone(t.type_class) self.assertIsNotNone(t.gpus) self.assertIsNone(t.successor) + self.assertIsNotNone(t.region_prices) + self.assertIsNotNone(t.addons.backups.region_prices) def test_get_type_by_id(self): """ @@ -546,6 +548,7 @@ def test_get_type_by_id(self): self.assertEqual(t.label, "Linode 1024") self.assertEqual(t.disk, 20480) self.assertEqual(t.type_class, "nanode") + self.assertEqual(t.region_prices[0].id, "us-east") def test_get_type_gpu(self): """ From 98511ad2326185944ca35376a8f048138779ac92 Mon Sep 17 00:00:00 2001 From: Ania Misiorek Date: Wed, 30 Aug 2023 17:16:27 -0400 Subject: [PATCH 143/379] linting --- test/unit/linode_client_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index b47c64ade..24a6d8ac3 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -470,7 +470,7 @@ def test_account_transfer(self): self.assertEqual(transfer.region_transfers[0].quota, 5010) self.assertEqual(transfer.region_transfers[0].billable, 0) - + class BetaProgramGroupTest(ClientBaseCase): """ Tests methods of the BetaProgramGroup From e2719eb278f575a54a2443b61dd96ea54d550dc2 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 11 Sep 2023 11:21:00 -0400 Subject: [PATCH 144/379] new: Support configuring a custom CA file path (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This change allows users to specify a custom CA file to be used for API requests made by the `LinodeClient` and `LinodeLoginClient` classes. This is useful for testing in alternate API environments. ## ✔️ How to Test ``` make testunit ``` --- Makefile | 4 ++++ linode_api4/linode_client.py | 8 +++++++- linode_api4/login_client.py | 12 +++++++++++- test/unit/base.py | 2 +- test/unit/linode_client_test.py | 22 ++++++++++++++++++++++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 7636f2192..1cb2da01b 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,10 @@ lint: build testint: python3 -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} +@PHONEY: testunit +testunit: + python3 -m python test/unit + @PHONEY: smoketest smoketest: pytest -m smoke test/integration --disable-warnings \ No newline at end of file diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 74e005ff5..66c53e336 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -62,6 +62,7 @@ def __init__( retry_rate_limit_interval=1.0, retry_max=5, retry_statuses=None, + ca_path=None, ): """ The main interface to the Linode API. @@ -96,11 +97,14 @@ def __init__( :param retry_statuses: Additional HTTP response statuses to retry on. By default, the client will retry on 408, 429, and 502 responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str """ self.base_url = base_url self._add_user_agent = user_agent self.token = token self.page_size = page_size + self.ca_path = ca_path retry_forcelist = [408, 429, 502] @@ -267,7 +271,9 @@ def _api_call( if data is not None: body = json.dumps(data) - response = method(url, headers=headers, data=body) + response = method( + url, headers=headers, data=body, verify=self.ca_path or True + ) warning = response.headers.get("Warning", None) if warning: diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 7531a7725..765dbbe2e 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -324,7 +324,11 @@ def serialize(scopes): class LinodeLoginClient: def __init__( - self, client_id, client_secret, base_url="https://login.linode.com" + self, + client_id, + client_secret, + base_url="https://login.linode.com", + ca_path=None, ): """ Create a new LinodeLoginClient. These clients do not make any requests @@ -339,10 +343,13 @@ def __init__( :param base_url: The URL for Linode's OAuth server. This should not be changed. :type base_url: str + :param ca_path: The path to the CA file to use for requests run by this client. + :type ca_path: str """ self.base_url = base_url self.client_id = client_id self.client_secret = client_secret + self.ca_path = ca_path def _login_uri(self, path): return "{}{}".format(self.base_url, path) @@ -423,6 +430,7 @@ def oauth_redirect(): "client_id": self.client_id, "client_secret": self.client_secret, }, + verify=self.ca_path or True, ) if r.status_code != 200: @@ -467,6 +475,7 @@ def refresh_oauth_token(self, refresh_token): "client_secret": self.client_secret, "refresh_token": refresh_token, }, + verify=self.ca_path or True, ) if r.status_code != 200: @@ -501,6 +510,7 @@ def expire_token(self, token): "client_secret": self.client_secret, "token": token, }, + verify=self.ca_path or True, ) if r.status_code != 200: diff --git a/test/unit/base.py b/test/unit/base.py index 95bbcd0a6..1af94ff5e 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -36,7 +36,7 @@ def load_json(url): return FIXTURES.get_fixture(formatted_url) -def mock_get(url, headers=None, data=None): +def mock_get(url, headers=None, data=None, **kwargs): """ Loads the response from a JSON file """ diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 86dbfd3e4..498c35509 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -258,6 +258,28 @@ def test_tag_create_with_entities(self): }, ) + def test_override_ca(self): + """ + Tests that the CA file used for API requests can be overridden. + """ + self.client.ca_path = "foobar" + + called = False + + old_get = self.client.session.get + + def get_mock(*params, verify=True, **kwargs): + nonlocal called + called = True + assert verify == "foobar" + return old_get(*params, **kwargs) + + self.client.session.get = get_mock + + self.client.linode.instances() + + assert called + class AccountGroupTest(ClientBaseCase): """ From 9915f6de9ac606c5e65c4e4c8b3944f9c3f6674f Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 19 Sep 2023 09:32:22 -0700 Subject: [PATCH 145/379] test: upload test report to account's object storage (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Storing test executions from dev/main branch to test account's object storage ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --- .github/workflows/e2e-test-pr.yml | 30 +++++++++++++++-- test/integration/conftest.py | 2 +- test/script/add_to_xml_test_report.py | 41 ++++++++++++++++++++++ test/script/test_report_upload_script.py | 43 ++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 test/script/add_to_xml_test_report.py create mode 100644 test/script/test_report_upload_script.py diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index af7e1bcaf..00391cf19 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -78,11 +78,37 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: make INTEGRATION_TEST_PATH="${{ inputs.test_path }}" testint - if: ${{ steps.validate-tests.outputs.match == '' }} + - name: Run Integration tests + run: | + timestamp=$(date +'%Y%m%d%H%M') + report_filename="${timestamp}_sdk_test_report.xml" + status=0 + if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --junitxml="${report_filename}"; then + echo "Tests failed, but attempting to upload results anyway" + fi env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + - name: Set release version env + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + + - name: Add additional information to XML report + run: | + filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + python test/script/add_to_xml_test_report.py \ + --branch_name "${{ env.RELEASE_VERSION }}" \ + --gha_run_id "$GITHUB_RUN_ID" \ + --gha_run_number "$GITHUB_RUN_NUMBER" \ + --xmlfile "${filename}" + + - name: Upload test results + run: | + report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + python3 test/script/test_report_upload_script.py "${report_filename}" + env: + LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} + LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + - uses: actions/github-script@v6 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index b3fa15fb2..69248b57a 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -3,7 +3,7 @@ import pytest -from linode_api4.linode_client import LinodeClient, LongviewSubscription +from linode_api4.linode_client import LinodeClient ENV_TOKEN_NAME = "LINODE_TOKEN" RUN_LONG_TESTS = "RUN_LONG_TESTS" diff --git a/test/script/add_to_xml_test_report.py b/test/script/add_to_xml_test_report.py new file mode 100644 index 000000000..d486028be --- /dev/null +++ b/test/script/add_to_xml_test_report.py @@ -0,0 +1,41 @@ +import argparse +import xml.etree.ElementTree as ET + +# Parse command-line arguments +parser = argparse.ArgumentParser( + description="Modify XML with workflow information" +) +parser.add_argument("--branch_name", required=True) +parser.add_argument("--gha_run_id", required=True) +parser.add_argument("--gha_run_number", required=True) +parser.add_argument( + "--xmlfile", required=True +) # Added argument for XML file path + +args = parser.parse_args() + +# Open and parse the XML file +xml_file_path = args.xmlfile +tree = ET.parse(xml_file_path) +root = tree.getroot() + +# Create new elements for the information +branch_name_element = ET.Element("branch_name") +branch_name_element.text = args.branch_name + +gha_run_id_element = ET.Element("gha_run_id") +gha_run_id_element.text = args.gha_run_id + +gha_run_number_element = ET.Element("gha_run_number") +gha_run_number_element.text = args.gha_run_number + +# Add the new elements to the root of the XML +root.append(branch_name_element) +root.append(gha_run_id_element) +root.append(gha_run_number_element) + +# Save the modified XML +modified_xml_file_path = xml_file_path # Overwrite it +tree.write(modified_xml_file_path) + +print(f"Modified XML saved to {modified_xml_file_path}") diff --git a/test/script/test_report_upload_script.py b/test/script/test_report_upload_script.py new file mode 100644 index 000000000..5dd1a9e31 --- /dev/null +++ b/test/script/test_report_upload_script.py @@ -0,0 +1,43 @@ +import os +import sys + +import boto3 +from botocore.exceptions import NoCredentialsError + +ACCESS_KEY = os.environ.get("LINODE_CLI_OBJ_ACCESS_KEY") +SECRET_KEY = os.environ.get("LINODE_CLI_OBJ_SECRET_KEY") +BUCKET_NAME = "dx-test-results" + +linode_obj_config = { + "aws_access_key_id": ACCESS_KEY, + "aws_secret_access_key": SECRET_KEY, + "endpoint_url": "https://us-southeast-1.linodeobjects.com", +} + + +def upload_to_linode_object_storage(file_name): + try: + s3 = boto3.client("s3", **linode_obj_config) + + s3.upload_file(Filename=file_name, Bucket=BUCKET_NAME, Key=file_name) + + print(f"Successfully uploaded {file_name} to Linode Object Storage.") + + except NoCredentialsError: + print( + "Credentials not available. Ensure you have set your AWS credentials." + ) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python upload_to_linode.py ") + sys.exit(1) + + file_name = sys.argv[1] + + if not file_name: + print("Error: The provided file name is empty or invalid.") + sys.exit(1) + + upload_to_linode_object_storage(file_name) From 182dec6d9ff78a182610cbddd8ac34e58ddd0c93 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Mon, 25 Sep 2023 10:51:22 -0400 Subject: [PATCH 146/379] Add integration test --- test/integration/conftest.py | 19 ++++++++++++++++++- test/integration/models/test_linode.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 69248b57a..a17a6f847 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -6,6 +6,8 @@ from linode_api4.linode_client import LinodeClient ENV_TOKEN_NAME = "LINODE_TOKEN" +ENV_API_URL_NAME = "LINODE_API_URL" +ENV_API_CA_NAME = "LINODE_API_CA" RUN_LONG_TESTS = "RUN_LONG_TESTS" @@ -13,6 +15,15 @@ def get_token(): return os.environ.get(ENV_TOKEN_NAME, None) +def get_api_url(): + return os.environ.get(ENV_API_URL_NAME, "https://api.linode.com/v4beta") + + +def get_api_ca_file(): + result = os.environ.get(ENV_API_CA_NAME, None) + return result if result != "" else None + + def run_long_tests(): return os.environ.get(RUN_LONG_TESTS, None) @@ -71,7 +82,13 @@ def ssh_key_gen(): @pytest.fixture(scope="session") def get_client(): token = get_token() - client = LinodeClient(token) + api_url = get_api_url() + api_ca_file = get_api_ca_file() + client = LinodeClient( + token, + base_url=api_url, + ca_path=api_ca_file, + ) return client diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 4427c7d5d..22f5709c7 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -398,6 +398,22 @@ def test_get_linode_types(get_client): assert "g6-nanode-1" in ids +def test_get_linode_types_overrides(get_client): + types = get_client.linode.types() + + target_types = [ + v + for v in types + if len(v.region_prices) > 0 and v.region_prices[0].hourly > 0 + ] + + assert len(target_types) > 0 + + for linode_type in target_types: + assert linode_type.region_prices[0].hourly >= 0 + assert linode_type.region_prices[0].monthly >= 0 + + def test_get_linode_type_by_id(get_client): pytest.skip( "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" From 448aa4cf3fdcab797041850967506f60e1b388ee Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 17 Oct 2023 22:22:46 -0400 Subject: [PATCH 147/379] Remove deprecated `pkg_resources` and drop Python 3.7 support (#337) --- .github/workflows/main.yml | 2 +- linode_api4/linode_client.py | 4 ++-- pyproject.toml | 2 +- setup.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4ea73fb40..0813c2581 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7','3.8','3.9','3.10','3.11'] + python-version: ['3.8','3.9','3.10','3.11'] steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 66c53e336..3a6f2b8b0 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -2,10 +2,10 @@ import json import logging +from importlib.metadata import version from typing import BinaryIO, Tuple from urllib import parse -import pkg_resources import requests from requests.adapters import HTTPAdapter, Retry @@ -36,7 +36,7 @@ from .paginated_list import PaginatedList from .util import drop_null_keys -package_version = pkg_resources.require("linode_api4")[0].version +package_version = version("linode_api4") logger = logging.getLogger(__name__) diff --git a/pyproject.toml b/pyproject.toml index ccb0ec5f1..dc391f2ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ line_length = 80 [tool.black] line-length = 80 -target-version = ["py37", "py38", "py39", "py310", "py311"] +target-version = ["py38", "py39", "py310", "py311", "py312"] [tool.autoflake] expand-star-imports = true diff --git a/setup.py b/setup.py index 9139ee5af..8c73e3384 100755 --- a/setup.py +++ b/setup.py @@ -103,11 +103,11 @@ def bake_version(v): # that you indicate whether you support Python 2, Python 3 or both. 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], # What does your project relate to? @@ -118,7 +118,7 @@ def bake_version(v): packages=find_packages(exclude=['contrib', 'docs', 'test', 'test.*']), # What do we need for this to run - python_requires=">=3.7", + python_requires=">=3.8", install_requires=[ "requests", From e2edd7132238cd35ff1e5d5919bc62488ecf0e53 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 19 Oct 2023 13:25:40 -0400 Subject: [PATCH 148/379] Consistent Python binary in makefile (#338) --- Makefile | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Makefile b/Makefile index 9ed3e4eb8..7482f9073 100644 --- a/Makefile +++ b/Makefile @@ -25,47 +25,47 @@ build: clean @PHONEY: release release: build - twine upload dist/* + $(PYTHON) -m twine upload dist/* @PHONEY: install install: clean requirements - python3 -m pip install . + $(PYTHON) -m pip install . @PHONEY: requirements requirements: - pip install -r requirements.txt -r requirements-dev.txt + $(PYTHON) -m pip install -r requirements.txt -r requirements-dev.txt @PHONEY: black black: - black linode_api4 test + $(PYTHON) -m black linode_api4 test @PHONEY: isort isort: - isort linode_api4 test + $(PYTHON) -m isort linode_api4 test @PHONEY: autoflake autoflake: - autoflake linode_api4 test + $(PYTHON) -m autoflake linode_api4 test @PHONEY: format format: black isort autoflake @PHONEY: lint lint: build - isort --check-only linode_api4 test - autoflake --check linode_api4 test - black --check --verbose linode_api4 test - pylint linode_api4 - twine check dist/* + $(PYTHON) -m isort --check-only linode_api4 test + $(PYTHON) -m autoflake --check linode_api4 test + $(PYTHON) -m black --check --verbose linode_api4 test + $(PYTHON) -m pylint linode_api4 + $(PYTHON) -m twine check dist/* @PHONEY: testint testint: - python3 -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} + $(PYTHON) -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} @PHONEY: testunit testunit: - python3 -m python test/unit + $(PYTHON) -m pytest test/unit @PHONEY: smoketest smoketest: - pytest -m smoke test/integration --disable-warnings \ No newline at end of file + $(PYTHON) -m pytest -m smoke test/integration --disable-warnings \ No newline at end of file From e6e0d454b0966fd912ade2ae11f9b737e09f0b61 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 26 Oct 2023 15:43:23 -0400 Subject: [PATCH 149/379] test: Integration tests for ip share (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description Make the tests case for `ip_addresses_share` to be integration test instead of using mocked data. ## ✔️ How to Test `pytest test/integration/models/test_networking.py` --- test/fixtures/networking_ips_share.json | 1 - test/integration/helpers.py | 6 ++ test/integration/models/test_networking.py | 106 +++++++++++++++------ 3 files changed, 81 insertions(+), 32 deletions(-) delete mode 100644 test/fixtures/networking_ips_share.json diff --git a/test/fixtures/networking_ips_share.json b/test/fixtures/networking_ips_share.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/networking_ips_share.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/integration/helpers.py b/test/integration/helpers.py index eee46f385..2ea66464b 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -13,6 +13,12 @@ def get_test_label(): return label +def get_rand_nanosec_test_label(): + unique_timestamp = str(time.time_ns()) + label = "IntTestSDK_" + unique_timestamp + return label + + def delete_instance_with_test_kw(paginated_list: PaginatedList): for i in paginated_list: try: diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py index d3b01e0bd..970ee6880 100644 --- a/test/integration/models/test_networking.py +++ b/test/integration/models/test_networking.py @@ -1,6 +1,8 @@ +from test.integration.helpers import get_rand_nanosec_test_label + import pytest -from linode_api4.objects import Firewall, IPAddress, IPv6Pool, IPv6Range +from linode_api4.objects import Firewall @pytest.mark.smoke @@ -15,37 +17,79 @@ def test_get_networking_rules(get_client, create_firewall): assert "outbound_policy" in str(rules) +def create_linode(get_client): + client = get_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_rand_nanosec_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + chosen_region, + image="linode/debian12", + label=label, + ) + + return linode_instance + + +@pytest.fixture +def create_linode_for_ip_share(get_client): + linode = create_linode(get_client) + + yield linode + + linode.delete() + + +@pytest.fixture +def create_linode_to_be_shared_with_ips(get_client): + linode = create_linode(get_client) + + yield linode + + linode.delete() + + @pytest.mark.smoke -def test_ip_addresses_share(self): +def test_ip_addresses_share( + get_client, create_linode_for_ip_share, create_linode_to_be_shared_with_ips +): """ Test that you can share IP addresses with Linode. """ - ip_share_url = "/networking/ips/share" - ips = ["127.0.0.1"] - linode_id = 12345 - with self.mock_post(ip_share_url) as m: - result = self.client.networking.ip_addresses_share(ips, linode_id) - - self.assertIsNotNone(result) - self.assertEqual(m.call_url, ip_share_url) - self.assertEqual( - m.call_data, - { - "ips": ips, - "linode": linode_id, - }, - ) - - # Test that entering an empty IP array is allowed. - with self.mock_post(ip_share_url) as m: - result = self.client.networking.ip_addresses_share([], linode_id) - - self.assertIsNotNone(result) - self.assertEqual(m.call_url, ip_share_url) - self.assertEqual( - m.call_data, - { - "ips": [], - "linode": linode_id, - }, - ) + + # create two linode instances and share the ip of instance1 with instance2 + linode_instance1 = create_linode_for_ip_share + linode_instance2 = create_linode_to_be_shared_with_ips + + get_client.networking.ip_addresses_share( + [linode_instance1.ips.ipv4.public[0]], linode_instance2.id + ) + + assert ( + linode_instance1.ips.ipv4.public[0].address + == linode_instance2.ips.ipv4.shared[0].address + ) + + +@pytest.mark.smoke +def test_ip_addresses_unshare( + get_client, create_linode_for_ip_share, create_linode_to_be_shared_with_ips +): + """ + Test that you can unshare IP addresses with Linode. + """ + + # create two linode instances and share the ip of instance1 with instance2 + linode_instance1 = create_linode_for_ip_share + linode_instance2 = create_linode_to_be_shared_with_ips + + get_client.networking.ip_addresses_share( + [linode_instance1.ips.ipv4.public[0]], linode_instance2.id + ) + + # unshared the ip with instance2 + get_client.networking.ip_addresses_share([], linode_instance2.id) + + assert [] == linode_instance2.ips.ipv4.shared From 7ffe2bda3f1b77064693bdcf8d0a10fb696f2d86 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Fri, 3 Nov 2023 01:37:50 -0700 Subject: [PATCH 150/379] test: Update E2E Tests, improve test fixture namin/teardowns (#341) --- test/integration/conftest.py | 84 ++++--- .../linode_client/test_linode_client.py | 112 ++++----- test/integration/models/test_account.py | 28 +-- test/integration/models/test_database.py | 212 +++++++++++++----- test/integration/models/test_domain.py | 26 +-- test/integration/models/test_firewall.py | 30 +-- test/integration/models/test_image.py | 16 +- test/integration/models/test_linode.py | 100 +++++---- test/integration/models/test_lke.py | 60 ++--- test/integration/models/test_longview.py | 20 +- test/integration/models/test_networking.py | 30 +-- test/integration/models/test_nodebalancer.py | 38 ++-- test/integration/models/test_tag.py | 10 +- test/integration/models/test_volume.py | 58 +++-- 14 files changed, 477 insertions(+), 347 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a17a6f847..25bae0710 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -3,6 +3,7 @@ import pytest +from linode_api4 import ApiError from linode_api4.linode_client import LinodeClient ENV_TOKEN_NAME = "LINODE_TOKEN" @@ -29,15 +30,15 @@ def run_long_tests(): @pytest.fixture(scope="session") -def create_linode(get_client): - client = get_client +def create_linode(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) yield linode_instance @@ -46,15 +47,15 @@ def create_linode(get_client): @pytest.fixture -def create_linode_for_pass_reset(get_client): - client = get_client +def create_linode_for_pass_reset(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) yield linode_instance, password @@ -80,7 +81,7 @@ def ssh_key_gen(): @pytest.fixture(scope="session") -def get_client(): +def test_linode_client(): token = get_token() api_url = get_api_url() api_ca_file = get_api_ca_file() @@ -93,8 +94,8 @@ def get_client(): @pytest.fixture -def set_account_settings(get_client): - client = get_client +def test_account_settings(test_linode_client): + client = test_linode_client account_settings = client.account.settings() account_settings._populated = True account_settings.network_helper = True @@ -103,10 +104,10 @@ def set_account_settings(get_client): @pytest.fixture(scope="session") -def create_domain(get_client): - client = get_client +def test_domain(test_linode_client): + client = test_linode_client - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) domain_addr = timestamp + "-example.com" soa_email = "pathiel-test123@linode.com" @@ -130,23 +131,36 @@ def create_domain(get_client): @pytest.fixture(scope="session") -def create_volume(get_client): - client = get_client - timestamp = str(int(time.time())) +def test_volume(test_linode_client): + client = test_linode_client + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp volume = client.volume_create(label=label, region="ap-west") yield volume - volume.delete() + timeout = 100 # give 100s for volume to be detached before deletion + + start_time = time.time() + + while time.time() - start_time < timeout: + try: + res = volume.delete() + if res: + break + else: + time.sleep(3) + except ApiError as e: + if time.time() - start_time > timeout: + raise e @pytest.fixture -def create_tag(get_client): - client = get_client +def test_tag(test_linode_client): + client = test_linode_client - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp tag = client.tag_create(label=label) @@ -157,10 +171,10 @@ def create_tag(get_client): @pytest.fixture -def create_nodebalancer(get_client): - client = get_client +def test_nodebalancer(test_linode_client): + client = test_linode_client - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp nodebalancer = client.nodebalancer_create(region="us-east", label=label) @@ -171,9 +185,9 @@ def create_nodebalancer(get_client): @pytest.fixture -def create_longview_client(get_client): - client = get_client - timestamp = str(int(time.time())) +def test_longview_client(test_linode_client): + client = test_linode_client + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp longview_client = client.longview.client_create(label=label) @@ -183,9 +197,9 @@ def create_longview_client(get_client): @pytest.fixture -def upload_sshkey(get_client, ssh_key_gen): +def test_sshkey(test_linode_client, ssh_key_gen): pub_key = ssh_key_gen[0] - client = get_client + client = test_linode_client key = client.profile.ssh_key_upload(pub_key, "IntTestSDK-sshkey") yield key @@ -194,8 +208,8 @@ def upload_sshkey(get_client, ssh_key_gen): @pytest.fixture -def create_ssh_keys_object_storage(get_client): - client = get_client +def ssh_keys_object_storage(test_linode_client): + client = test_linode_client label = "TestSDK-obj-storage-key" key = client.object_storage.keys_create(label) @@ -205,8 +219,8 @@ def create_ssh_keys_object_storage(get_client): @pytest.fixture(scope="session") -def create_firewall(get_client): - client = get_client +def test_firewall(test_linode_client): + client = test_linode_client rules = { "outbound": [], "outbound_policy": "DROP", @@ -224,8 +238,8 @@ def create_firewall(get_client): @pytest.fixture -def create_oauth_client(get_client): - client = get_client +def test_oauth_client(test_linode_client): + client = test_linode_client oauth_client = client.account.oauth_client_create( "test-oauth-client", "https://localhost/oauth/callback" ) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 0df8bc8d7..08b7e2383 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -9,14 +9,14 @@ @pytest.fixture(scope="session", autouse=True) -def setup_client_and_linode(get_client): - client = get_client +def setup_client_and_linode(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() linode_instance, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) yield client, linode_instance @@ -50,7 +50,7 @@ def test_get_account(setup_client_and_linode): def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): client = setup_client_and_linode[0] - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) domain_addr = timestamp + "example.com" try: domain = client.domain_create(domain=domain_addr) @@ -59,9 +59,9 @@ def test_fails_to_create_domain_without_soa_email(setup_client_and_linode): @pytest.mark.smoke -def test_get_domains(get_client, create_domain): - client = get_client - domain = create_domain +def test_get_domains(test_linode_client, test_domain): + client = test_linode_client + domain = test_domain domain_dict = client.domains() dom_list = [i.domain for i in domain_dict] @@ -117,9 +117,9 @@ def test_fails_to_delete_predefined_images(setup_client_and_linode): assert e.status == 403 -def test_get_volume(get_client, create_volume): - client = get_client - label = create_volume.label +def test_get_volume(test_linode_client, test_volume): + client = test_linode_client + label = test_volume.label volume_dict = client.volumes() @@ -128,9 +128,9 @@ def test_get_volume(get_client, create_volume): assert label in volume_label_list -def test_get_tag(get_client, create_tag): - client = get_client - label = create_tag.label +def test_get_tag(test_linode_client, test_tag): + client = test_linode_client + label = test_tag.label tags = client.tags() @@ -140,13 +140,13 @@ def test_get_tag(get_client, create_tag): def test_create_tag_with_id( - setup_client_and_linode, create_nodebalancer, create_domain, create_volume + setup_client_and_linode, test_nodebalancer, test_domain, test_volume ): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] - nodebalancer = create_nodebalancer - domain = create_domain - volume = create_volume + nodebalancer = test_nodebalancer + domain = test_domain + volume = test_volume label = get_test_label() @@ -170,13 +170,13 @@ def test_create_tag_with_id( @pytest.mark.smoke def test_create_tag_with_entities( - setup_client_and_linode, create_nodebalancer, create_domain, create_volume + setup_client_and_linode, test_nodebalancer, test_domain, test_volume ): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] - nodebalancer = create_nodebalancer - domain = create_domain - volume = create_volume + nodebalancer = test_nodebalancer + domain = test_domain + volume = test_volume label = get_test_label() @@ -195,8 +195,8 @@ def test_create_tag_with_entities( # AccountGroupTests -def test_get_account_settings(get_client): - client = get_client +def test_get_account_settings(test_linode_client): + client = test_linode_client account_settings = client.account.settings() assert account_settings._populated == True @@ -209,14 +209,14 @@ def test_get_account_settings(get_client): # LinodeGroupTests -def test_create_linode_instance_without_image(get_client): - client = get_client +def test_create_linode_instance_without_image(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() linode_instance = client.linode.instance_create( - "g5-standard-4", chosen_region, label=label + "g6-nanode-1", chosen_region, label=label ) assert linode_instance.label == label @@ -231,22 +231,22 @@ def test_create_linode_instance_without_image(get_client): def test_create_linode_instance_with_image(setup_client_and_linode): linode = setup_client_and_linode[1] - assert re.search("linode/debian9", str(linode.image)) + assert re.search("linode/debian10", str(linode.image)) # LongviewGroupTests -def test_get_longview_clients(get_client, create_longview_client): - client = get_client +def test_get_longview_clients(test_linode_client, test_longview_client): + client = test_linode_client longview_client = client.longview.clients() client_labels = [i.label for i in longview_client] - assert create_longview_client.label in client_labels + assert test_longview_client.label in client_labels -def test_client_create_with_label(get_client): - client = get_client +def test_client_create_with_label(test_linode_client): + client = test_linode_client label = get_test_label() longview_client = client.longview.client_create(label=label) @@ -266,15 +266,15 @@ def test_client_create_with_label(get_client): # LKEGroupTest -def test_kube_version(get_client): - client = get_client +def test_kube_version(test_linode_client): + client = test_linode_client lke_version = client.lke.versions() assert re.search("[0-9].[0-9]+", lke_version.first().id) -def test_cluster_create_with_api_objects(get_client): - client = get_client +def test_cluster_create_with_api_objects(test_linode_client): + client = test_linode_client node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] region = client.regions().first() @@ -291,9 +291,9 @@ def test_cluster_create_with_api_objects(get_client): assert res -def test_fails_to_create_cluster_with_invalid_version(get_client): +def test_fails_to_create_cluster_with_invalid_version(test_linode_client): invalid_version = "a.12" - client = get_client + client = test_linode_client try: cluster = client.lke.cluster_create( @@ -310,19 +310,19 @@ def test_fails_to_create_cluster_with_invalid_version(get_client): # ProfileGroupTest -def test_get_sshkeys(get_client, upload_sshkey): - client = get_client +def test_get_sshkeys(test_linode_client, test_sshkey): + client = test_linode_client ssh_keys = client.profile.ssh_keys() ssh_labels = [i.label for i in ssh_keys] - assert upload_sshkey.label in ssh_labels + assert test_sshkey.label in ssh_labels -def test_ssh_key_create(upload_sshkey, ssh_key_gen): +def test_ssh_key_create(test_sshkey, ssh_key_gen): pub_key = ssh_key_gen[0] - key = upload_sshkey + key = test_sshkey assert pub_key == key._raw_json["ssh_key"] @@ -330,8 +330,8 @@ def test_ssh_key_create(upload_sshkey, ssh_key_gen): # ObjectStorageGroupTests -def test_get_object_storage_clusters(get_client): - client = get_client +def test_get_object_storage_clusters(test_linode_client): + client = test_linode_client clusters = client.object_storage.clusters() @@ -339,9 +339,9 @@ def test_get_object_storage_clusters(get_client): assert "us-east" in clusters[0].region.id -def test_get_keys(get_client, create_ssh_keys_object_storage): - client = get_client - key = create_ssh_keys_object_storage +def test_get_keys(test_linode_client, ssh_keys_object_storage): + client = test_linode_client + key = ssh_keys_object_storage keys = client.object_storage.keys() key_labels = [i.label for i in keys] @@ -349,10 +349,12 @@ def test_get_keys(get_client, create_ssh_keys_object_storage): assert key.label in key_labels -def test_keys_create(get_client, create_ssh_keys_object_storage): - key = create_ssh_keys_object_storage +def test_keys_create(test_linode_client, ssh_keys_object_storage): + key = ssh_keys_object_storage - assert type(key) == type(ObjectStorageKeys(client=get_client, id="123")) + assert type(key) == type( + ObjectStorageKeys(client=test_linode_client, id="123") + ) # NetworkingGroupTests @@ -362,8 +364,8 @@ def test_keys_create(get_client, create_ssh_keys_object_storage): @pytest.fixture -def create_firewall_with_inbound_outbound_rules(get_client): - client = get_client +def create_firewall_with_inbound_outbound_rules(test_linode_client): + client = test_linode_client label = get_test_label() + "-firewall" rules = { "outbound": [ @@ -398,9 +400,9 @@ def create_firewall_with_inbound_outbound_rules(get_client): def test_get_firewalls_with_inbound_outbound_rules( - get_client, create_firewall_with_inbound_outbound_rules + test_linode_client, create_firewall_with_inbound_outbound_rules ): - client = get_client + client = test_linode_client firewalls = client.networking.firewalls() firewall = create_firewall_with_inbound_outbound_rules diff --git a/test/integration/models/test_account.py b/test/integration/models/test_account.py index 308d2425b..9c2efc787 100644 --- a/test/integration/models/test_account.py +++ b/test/integration/models/test_account.py @@ -14,8 +14,8 @@ @pytest.mark.smoke -def test_get_account(get_client): - client = get_client +def test_get_account(test_linode_client): + client = test_linode_client account = client.account() account_id = account.id account_get = client.load(Account, account_id) @@ -33,8 +33,8 @@ def test_get_account(get_client): assert account_get.tax_id == account.tax_id -def test_get_login(get_client): - client = get_client +def test_get_login(test_linode_client): + client = test_linode_client login = client.load(Login(client, "", {}), "") updated_time = int(time.mktime(getattr(login, "_last_updated").timetuple())) @@ -48,8 +48,8 @@ def test_get_login(get_client): assert login_updated < 15 -def test_get_account_settings(get_client): - client = get_client +def test_get_account_settings(test_linode_client): + client = test_linode_client account_settings = client.load(AccountSettings(client, ""), "") assert "managed" in str(account_settings._raw_json) @@ -60,15 +60,15 @@ def test_get_account_settings(get_client): @pytest.mark.smoke -def test_latest_get_event(get_client): - client = get_client +def test_latest_get_event(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() linode, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) events = client.load(Event, "") @@ -80,17 +80,17 @@ def test_latest_get_event(get_client): assert label in latest_event["entity"]["label"] -def test_get_oathclient(get_client, create_oauth_client): - client = get_client +def test_get_oathclient(test_linode_client, test_oauth_client): + client = test_linode_client - oauth_client = client.load(OAuthClient, create_oauth_client.id) + oauth_client = client.load(OAuthClient, test_oauth_client.id) assert "test-oauth-client" == oauth_client.label assert "https://localhost/oauth/callback" == oauth_client.redirect_uri -def test_get_user(get_client): - client = get_client +def test_get_user(test_linode_client): + client = test_linode_client events = client.load(Event, "") diff --git a/test/integration/models/test_database.py b/test/integration/models/test_database.py index 974c5c923..7cd41be66 100644 --- a/test/integration/models/test_database.py +++ b/test/integration/models/test_database.py @@ -34,8 +34,11 @@ def get_postgres_db_status(client: LinodeClient, db_id, status: str): @pytest.fixture(scope="session") -def test_create_sql_db(get_client): - client = get_client +def test_create_sql_db(test_linode_client): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + client = test_linode_client label = get_test_label() + "-sqldb" region = "us-east" engine_id = get_db_engine_id(client, "mysql") @@ -61,8 +64,11 @@ def get_db_status(): @pytest.fixture(scope="session") -def test_create_postgres_db(get_client): - client = get_client +def test_create_postgres_db(test_linode_client): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + client = test_linode_client label = get_test_label() + "-postgresqldb" region = "us-east" engine_id = get_db_engine_id(client, "postgresql") @@ -88,8 +94,11 @@ def get_db_status(): # ------- SQL DB Test cases ------- -def test_get_types(get_client): - client = get_client +def test_get_types(test_linode_client): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + client = test_linode_client types = client.database.types() assert (types[0].type_class, "nanode") @@ -97,8 +106,11 @@ def test_get_types(get_client): assert (types[0].engines.mongodb[0].price.monthly, 15) -def test_get_engines(get_client): - client = get_client +def test_get_engines(test_linode_client): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + client = test_linode_client engines = client.database.engines() for e in engines: @@ -107,15 +119,21 @@ def test_get_engines(get_client): assert e.id == e.engine + "/" + e.version -def test_database_instance(get_client, test_create_sql_db): - dbs = get_client.database.mysql_instances() +def test_database_instance(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + dbs = test_linode_client.database.mysql_instances() assert str(test_create_sql_db.id) in str(dbs.lists) # ------- POSTGRESQL DB Test cases ------- -def test_get_sql_db_instance(get_client, test_create_sql_db): - dbs = get_client.database.mysql_instances() +def test_get_sql_db_instance(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + dbs = test_linode_client.database.mysql_instances() database = "" for db in dbs: if db.id == test_create_sql_db.id: @@ -128,8 +146,11 @@ def test_get_sql_db_instance(get_client, test_create_sql_db): assert "-mysql-primary.servers.linodedb.net" in database.hosts.primary -def test_update_sql_db(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_update_sql_db(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) new_allow_list = ["192.168.0.1/32"] label = get_test_label() + "updatedSQLDB" @@ -140,10 +161,15 @@ def test_update_sql_db(get_client, test_create_sql_db): res = db.save() - database = get_client.load(MySQLDatabase, test_create_sql_db.id) + database = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) wait_for_condition( - 30, 300, get_sql_db_status, get_client, test_create_sql_db.id, "active" + 30, + 300, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", ) assert res @@ -152,12 +178,20 @@ def test_update_sql_db(get_client, test_create_sql_db): assert database.updates.day_of_week == 2 -def test_create_sql_backup(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_create_sql_backup(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) label = "database_backup_test" wait_for_condition( - 30, 300, get_sql_db_status, get_client, test_create_sql_db.id, "active" + 30, + 300, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", ) db.backup_create(label=label, target="secondary") @@ -166,7 +200,7 @@ def test_create_sql_backup(get_client, test_create_sql_db): 10, 300, get_sql_db_status, - get_client, + test_linode_client, test_create_sql_db.id, "backing_up", ) @@ -175,7 +209,12 @@ def test_create_sql_backup(get_client, test_create_sql_db): # list backup and most recently created one is first element of the array wait_for_condition( - 30, 600, get_sql_db_status, get_client, test_create_sql_db.id, "active" + 30, + 600, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", ) backup = db.backups[0] @@ -188,8 +227,11 @@ def test_create_sql_backup(get_client, test_create_sql_db): backup.delete() -def test_sql_backup_restore(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_sql_backup_restore(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) try: backup = db.backups[0] except IndexError as e: @@ -203,7 +245,7 @@ def test_sql_backup_restore(get_client, test_create_sql_db): 10, 300, get_sql_db_status, - get_client, + test_linode_client, test_create_sql_db.id, "restoring", ) @@ -211,20 +253,31 @@ def test_sql_backup_restore(get_client, test_create_sql_db): assert db.status == "restoring" wait_for_condition( - 30, 1000, get_sql_db_status, get_client, test_create_sql_db.id, "active" + 30, + 1000, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", ) assert db.status == "active" -def test_get_sql_ssl(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_get_sql_ssl(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) assert "ca_certificate" in str(db.ssl) -def test_sql_patch(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_sql_patch(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) db.patch() @@ -232,7 +285,7 @@ def test_sql_patch(get_client, test_create_sql_db): 10, 300, get_sql_db_status, - get_client, + test_linode_client, test_create_sql_db.id, "updating", ) @@ -240,21 +293,32 @@ def test_sql_patch(get_client, test_create_sql_db): assert db.status == "updating" wait_for_condition( - 30, 1000, get_sql_db_status, get_client, test_create_sql_db.id, "active" + 30, + 1000, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", ) assert db.status == "active" -def test_get_sql_credentials(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_get_sql_credentials(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) assert db.credentials.username == "linroot" assert db.credentials.password -def test_reset_sql_credentials(get_client, test_create_sql_db): - db = get_client.load(MySQLDatabase, test_create_sql_db.id) +def test_reset_sql_credentials(test_linode_client, test_create_sql_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) old_pass = str(db.credentials.password) @@ -268,8 +332,11 @@ def test_reset_sql_credentials(get_client, test_create_sql_db): # ------- POSTGRESQL DB Test cases ------- -def test_get_postgres_db_instance(get_client, test_create_postgres_db): - dbs = get_client.database.postgresql_instances() +def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + dbs = test_linode_client.database.postgresql_instances() for db in dbs: if db.id == test_create_postgres_db.id: @@ -282,8 +349,11 @@ def test_get_postgres_db_instance(get_client, test_create_postgres_db): assert "pgsql-primary.servers.linodedb.net" in database.hosts.primary -def test_update_postgres_db(get_client, test_create_postgres_db): - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) +def test_update_postgres_db(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) new_allow_list = ["192.168.0.1/32"] label = get_test_label() + "updatedPostgresDB" @@ -294,13 +364,15 @@ def test_update_postgres_db(get_client, test_create_postgres_db): res = db.save() - database = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + database = test_linode_client.load( + PostgreSQLDatabase, test_create_postgres_db.id + ) wait_for_condition( 30, 1000, get_postgres_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "active", ) @@ -311,18 +383,21 @@ def test_update_postgres_db(get_client, test_create_postgres_db): assert database.updates.day_of_week == 2 -def test_create_postgres_backup(get_client, test_create_postgres_db): +def test_create_postgres_backup(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) pytest.skip( "Failing due to '400: The backup snapshot request failed, please contact support.'" ) - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) label = "database_backup_test" wait_for_condition( 30, 1000, get_postgres_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "active", ) @@ -334,7 +409,7 @@ def test_create_postgres_backup(get_client, test_create_postgres_db): 10, 300, get_sql_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "backing_up", ) @@ -346,7 +421,7 @@ def test_create_postgres_backup(get_client, test_create_postgres_db): 30, 600, get_sql_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "active", ) @@ -358,8 +433,11 @@ def test_create_postgres_backup(get_client, test_create_postgres_db): assert backup.database_id == test_create_postgres_db.id -def test_postgres_backup_restore(get_client, test_create_postgres_db): - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) +def test_postgres_backup_restore(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) try: backup = db.backups[0] @@ -374,7 +452,7 @@ def test_postgres_backup_restore(get_client, test_create_postgres_db): 30, 1000, get_postgres_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "restoring", ) @@ -383,7 +461,7 @@ def test_postgres_backup_restore(get_client, test_create_postgres_db): 30, 1000, get_postgres_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "active", ) @@ -391,14 +469,20 @@ def test_postgres_backup_restore(get_client, test_create_postgres_db): assert db.status == "active" -def test_get_postgres_ssl(get_client, test_create_postgres_db): - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) +def test_get_postgres_ssl(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) assert "ca_certificate" in str(db.ssl) -def test_postgres_patch(get_client, test_create_postgres_db): - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) +def test_postgres_patch(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) db.patch() @@ -406,7 +490,7 @@ def test_postgres_patch(get_client, test_create_postgres_db): 10, 300, get_postgres_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "updating", ) @@ -417,7 +501,7 @@ def test_postgres_patch(get_client, test_create_postgres_db): 30, 600, get_postgres_db_status, - get_client, + test_linode_client, test_create_postgres_db.id, "active", ) @@ -425,15 +509,23 @@ def test_postgres_patch(get_client, test_create_postgres_db): assert db.status == "active" -def test_get_postgres_credentials(get_client, test_create_postgres_db): - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) +def test_get_postgres_credentials(test_linode_client, test_create_postgres_db): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) assert db.credentials.username == "linpostgres" assert db.credentials.password -def test_reset_postgres_credentials(get_client, test_create_postgres_db): - db = get_client.load(PostgreSQLDatabase, test_create_postgres_db.id) +def test_reset_postgres_credentials( + test_linode_client, test_create_postgres_db +): + pytest.skip( + "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" + ) + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) old_pass = str(db.credentials.password) diff --git a/test/integration/models/test_domain.py b/test/integration/models/test_domain.py index 2144fff55..cf5a54710 100644 --- a/test/integration/models/test_domain.py +++ b/test/integration/models/test_domain.py @@ -8,16 +8,16 @@ @pytest.mark.smoke -def test_get_domain_record(get_client, create_domain): +def test_get_domain_record(test_linode_client, test_domain): dr = DomainRecord( - get_client, create_domain.records.first().id, create_domain.id + test_linode_client, test_domain.records.first().id, test_domain.id ) - assert dr.id == create_domain.records.first().id + assert dr.id == test_domain.records.first().id -def test_save_null_values_excluded(get_client, create_domain): - domain = get_client.load(Domain, create_domain.id) +def test_save_null_values_excluded(test_linode_client, test_domain): + domain = test_linode_client.load(Domain, test_domain.id) domain.type = "master" domain.master_ips = ["127.0.0.1"] @@ -26,8 +26,8 @@ def test_save_null_values_excluded(get_client, create_domain): assert res -def test_zone_file_view(get_client, create_domain): - domain = get_client.load(Domain, create_domain.id) +def test_zone_file_view(test_linode_client, test_domain): + domain = test_linode_client.load(Domain, test_domain.id) def get_zone_file_view(): res = domain.zone_file_view() @@ -39,13 +39,13 @@ def get_zone_file_view(): assert re.search("ns[0-9].linode.com", str(domain.zone_file_view())) -def test_clone(get_client, create_domain): - domain = get_client.load(Domain, create_domain.id) - timestamp = str(int(time.time())) +def test_clone(test_linode_client, test_domain): + domain = test_linode_client.load(Domain, test_domain.id) + timestamp = str(time.time_ns()) dom = "example.clone-" + timestamp + "-IntTestSDK.org" domain.clone(dom) - ds = get_client.domains() + ds = test_linode_client.domains() time.sleep(1) @@ -54,8 +54,8 @@ def test_clone(get_client, create_domain): assert dom in domains -def test_import(get_client, create_domain): +def test_import(test_linode_client, test_domain): pytest.skip( 'Currently failing with message: linode_api4.errors.ApiError: 400: An unknown error occured. Please open a ticket for further assistance. Command: domain_import(domain, "google.ca")' ) - domain = get_client.load(Domain, create_domain.id) + domain = test_linode_client.load(Domain, test_domain.id) diff --git a/test/integration/models/test_firewall.py b/test/integration/models/test_firewall.py index c45812677..e9e7b8bcc 100644 --- a/test/integration/models/test_firewall.py +++ b/test/integration/models/test_firewall.py @@ -6,14 +6,14 @@ @pytest.fixture(scope="session") -def create_linode_fw(get_client): - client = get_client +def linode_fw(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = "linode_instance_fw_device" linode_instance, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) yield linode_instance @@ -22,8 +22,8 @@ def create_linode_fw(get_client): @pytest.mark.smoke -def test_get_firewall_rules(get_client, create_firewall): - firewall = get_client.load(Firewall, create_firewall.id) +def test_get_firewall_rules(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) rules = firewall.rules assert rules.inbound_policy in ["ACCEPT", "DROP"] @@ -31,8 +31,8 @@ def test_get_firewall_rules(get_client, create_firewall): @pytest.mark.smoke -def test_update_firewall_rules(get_client, create_firewall): - firewall = get_client.load(Firewall, create_firewall.id) +def test_update_firewall_rules(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) new_rules = { "inbound": [ { @@ -56,26 +56,26 @@ def test_update_firewall_rules(get_client, create_firewall): time.sleep(1) - firewall = get_client.load(Firewall, create_firewall.id) + firewall = test_linode_client.load(Firewall, test_firewall.id) assert firewall.rules.inbound_policy == "ACCEPT" assert firewall.rules.outbound_policy == "DROP" -def test_get_devices(get_client, create_linode_fw, create_firewall): - linode = create_linode_fw +def test_get_devices(test_linode_client, linode_fw, test_firewall): + linode = linode_fw - create_firewall.device_create(int(linode.id)) + test_firewall.device_create(int(linode.id)) - firewall = get_client.load(Firewall, create_firewall.id) + firewall = test_linode_client.load(Firewall, test_firewall.id) assert len(firewall.devices) > 0 -def test_get_device(get_client, create_firewall, create_linode_fw): - firewall = create_firewall +def test_get_device(test_linode_client, test_firewall, linode_fw): + firewall = test_firewall - firewall_device = get_client.load( + firewall_device = test_linode_client.load( FirewallDevice, firewall.devices.first().id, firewall.id ) diff --git a/test/integration/models/test_image.py b/test/integration/models/test_image.py index fe828643e..239e65784 100644 --- a/test/integration/models/test_image.py +++ b/test/integration/models/test_image.py @@ -10,37 +10,37 @@ @pytest.fixture(scope="session") -def image_upload(get_client): +def image_upload(test_linode_client): label = get_test_label() + "_image" - get_client.image_create_upload( + test_linode_client.image_create_upload( label, "us-east", "integration test image upload" ) - image = get_client.images()[0] + image = test_linode_client.images()[0] yield image image.delete() - images = get_client.images() + images = test_linode_client.images() delete_instance_with_test_kw(images) @pytest.mark.smoke -def test_get_image(get_client, image_upload): - image = get_client.load(Image, image_upload.id) +def test_get_image(test_linode_client, image_upload): + image = test_linode_client.load(Image, image_upload.id) assert image.label == image_upload.label -def test_image_create_upload(get_client): +def test_image_create_upload(test_linode_client): test_image_content = ( b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) label = get_test_label() + "_image" - image = get_client.image_upload( + image = test_linode_client.image_upload( label, "us-east", BytesIO(test_image_content), diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 22f5709c7..9f6f76d65 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -12,8 +12,8 @@ @pytest.fixture(scope="session") -def create_linode_with_volume_firewall(get_client): - client = get_client +def linode_with_volume_firewall(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() @@ -26,9 +26,9 @@ def create_linode_with_volume_firewall(get_client): } linode_instance, password = client.linode.instance_create( - "g5-standard-4", + "g6-nanode-1", chosen_region, - image="linode/debian9", + image="linode/debian10", label=label + "_modlinode", ) @@ -48,27 +48,27 @@ def create_linode_with_volume_firewall(get_client): firewall.delete() - linode_instance.delete() - volume.detach() - # wait for volume detach, can't currently get the attach/unattached status via SDK + # wait for volume detach, can't currently get the attached/unattached status via SDK time.sleep(30) volume.delete() + linode_instance.delete() + @pytest.mark.smoke @pytest.fixture -def create_linode_for_long_running_tests(get_client): - client = get_client +def create_linode_for_long_running_tests(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() linode_instance, password = client.linode.instance_create( - "g5-standard-4", + "g6-nanode-1", chosen_region, - image="linode/debian9", + image="linode/debian10", label=label + "_long_tests", ) @@ -82,15 +82,15 @@ def get_status(linode: Instance, status: str): return linode.status == status -def test_get_linode(get_client, create_linode_with_volume_firewall): - linode = get_client.load(Instance, create_linode_with_volume_firewall.id) +def test_get_linode(test_linode_client, linode_with_volume_firewall): + linode = test_linode_client.load(Instance, linode_with_volume_firewall.id) - assert linode.label == create_linode_with_volume_firewall.label - assert linode.id == create_linode_with_volume_firewall.id + assert linode.label == linode_with_volume_firewall.label + assert linode.id == linode_with_volume_firewall.id -def test_linode_transfer(get_client, create_linode_with_volume_firewall): - linode = get_client.load(Instance, create_linode_with_volume_firewall.id) +def test_linode_transfer(test_linode_client, linode_with_volume_firewall): + linode = test_linode_client.load(Instance, linode_with_volume_firewall.id) transfer = linode.transfer @@ -99,24 +99,24 @@ def test_linode_transfer(get_client, create_linode_with_volume_firewall): assert "billable" in str(transfer) -def test_linode_rebuild(get_client): - client = get_client +def test_linode_rebuild(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) wait_for_condition(10, 100, get_status, linode, "running") - retry_sending_request(3, linode.rebuild, "linode/debian9") + retry_sending_request(3, linode.rebuild, "linode/debian10") wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" - assert linode.image.id == "linode/debian9" + assert linode.image.id == "linode/debian10" wait_for_condition(10, 300, get_status, linode, "running") @@ -149,16 +149,16 @@ def test_update_linode(create_linode): assert linode.label == new_label -def test_delete_linode(get_client): - client = get_client +def test_delete_linode(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() linode_instance, password = client.linode.instance_create( - "g5-standard-4", + "g6-nanode-1", chosen_region, - image="linode/debian9", + image="linode/debian10", label=label + "_linode", ) @@ -224,10 +224,10 @@ def test_linode_resize(create_linode_for_long_running_tests): def test_linode_resize_with_class( - get_client, create_linode_for_long_running_tests + test_linode_client, create_linode_for_long_running_tests ): linode = create_linode_for_long_running_tests - ltype = Type(get_client, "g6-standard-6") + ltype = Type(test_linode_client, "g6-standard-6") wait_for_condition(10, 100, get_status, linode, "running") @@ -263,8 +263,8 @@ def test_linode_boot_with_config(create_linode): assert linode.status == "running" -def test_linode_firewalls(create_linode_with_volume_firewall): - linode = create_linode_with_volume_firewall +def test_linode_firewalls(linode_with_volume_firewall): + linode = linode_with_volume_firewall firewalls = linode.firewalls() @@ -272,8 +272,8 @@ def test_linode_firewalls(create_linode_with_volume_firewall): assert "TestSDK" in firewalls[0].label -def test_linode_volumes(create_linode_with_volume_firewall): - linode = create_linode_with_volume_firewall +def test_linode_volumes(linode_with_volume_firewall): + linode = linode_with_volume_firewall volumes = linode.volumes() @@ -281,11 +281,11 @@ def test_linode_volumes(create_linode_with_volume_firewall): assert "TestSDK" in volumes[0].label -def test_linode_disk_duplicate(get_client, create_linode): +def test_linode_disk_duplicate(test_linode_client, create_linode): pytest.skip("Need to find out the space sizing when duplicating disks") linode = create_linode - disk = get_client.load(Disk, linode.disks[0].id, linode.id) + disk = test_linode_client.load(Disk, linode.disks[0].id, linode.id) try: dup_disk = disk.duplicate() @@ -323,14 +323,14 @@ def test_linode_ips(create_linode): assert ips.ipv4.public[0].address == linode.ipv4[0] -def test_linode_initate_migration(get_client): - client = get_client +def test_linode_initate_migration(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_test_label() + "_migration" linode, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) wait_for_condition(10, 100, get_status, linode, "running") @@ -373,24 +373,26 @@ def test_config_update_interfaces(create_linode): assert "cool-vlan" in str(config.interfaces) -def test_get_config(get_client, create_linode): +def test_get_config(test_linode_client, create_linode): pytest.skip( "Model get method: client.load(Config, 123, 123) does not work..." ) linode = create_linode - json = get_client.get( + json = test_linode_client.get( "linode/instances/" + str(linode.id) + "/configs/" + str(linode.configs[0].id) ) - config = Config(get_client, linode.id, linode.configs[0].id, json=json) + config = Config( + test_linode_client, linode.id, linode.configs[0].id, json=json + ) assert config.id == linode.configs[0].id -def test_get_linode_types(get_client): - types = get_client.linode.types() +def test_get_linode_types(test_linode_client): + types = test_linode_client.linode.types() ids = [i.id for i in types] @@ -398,8 +400,8 @@ def test_get_linode_types(get_client): assert "g6-nanode-1" in ids -def test_get_linode_types_overrides(get_client): - types = get_client.linode.types() +def test_get_linode_types_overrides(test_linode_client): + types = test_linode_client.linode.types() target_types = [ v @@ -414,7 +416,7 @@ def test_get_linode_types_overrides(get_client): assert linode_type.region_prices[0].monthly >= 0 -def test_get_linode_type_by_id(get_client): +def test_get_linode_type_by_id(test_linode_client): pytest.skip( "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" ) @@ -426,23 +428,23 @@ def test_get_linode_type_gpu(): ) -def test_save_linode_noforce(get_client, create_linode): +def test_save_linode_noforce(test_linode_client, create_linode): linode = create_linode old_label = linode.label linode.label = "updated_no_force_label" linode.save(force=False) - linode = get_client.load(Instance, linode.id) + linode = test_linode_client.load(Instance, linode.id) assert old_label != linode.label -def test_save_linode_force(get_client, create_linode): +def test_save_linode_force(test_linode_client, create_linode): linode = create_linode old_label = linode.label linode.label = "updated_force_label" linode.save(force=False) - linode = get_client.load(Instance, linode.id) + linode = test_linode_client.load(Instance, linode.id) assert old_label != linode.label diff --git a/test/integration/models/test_lke.py b/test/integration/models/test_lke.py index 11df1cbcc..45b1ac8a1 100644 --- a/test/integration/models/test_lke.py +++ b/test/integration/models/test_lke.py @@ -12,14 +12,16 @@ @pytest.fixture(scope="session") -def create_lke_cluster(get_client): - node_type = get_client.linode.types()[1] # g6-standard-1 - version = get_client.lke.versions()[0] - region = get_client.regions().first() - node_pools = get_client.lke.node_pool(node_type, 3) +def lke_cluster(test_linode_client): + node_type = test_linode_client.linode.types()[1] # g6-standard-1 + version = test_linode_client.lke.versions()[0] + region = test_linode_client.regions().first() + node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" - cluster = get_client.lke.cluster_create(region, label, node_pools, version) + cluster = test_linode_client.lke.cluster_create( + region, label, node_pools, version + ) yield cluster @@ -36,24 +38,24 @@ def get_node_status(cluster: LKECluster, status: str): @pytest.mark.smoke -def test_get_lke_clusters(get_client, create_lke_cluster): - cluster = get_client.load(LKECluster, create_lke_cluster.id) +def test_get_lke_clusters(test_linode_client, lke_cluster): + cluster = test_linode_client.load(LKECluster, lke_cluster.id) - assert cluster._raw_json == create_lke_cluster._raw_json + assert cluster._raw_json == lke_cluster._raw_json -def test_get_lke_pool(get_client, create_lke_cluster): +def test_get_lke_pool(test_linode_client, lke_cluster): pytest.skip("client.load(LKENodePool, 123, 123) does not work") - cluster = create_lke_cluster + cluster = lke_cluster - pool = get_client.load(LKENodePool, cluster.pools[0].id, cluster.id) + pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) assert cluster.pools[0]._raw_json == pool -def test_cluster_dashboard_url_view(create_lke_cluster): - cluster = create_lke_cluster +def test_cluster_dashboard_url_view(lke_cluster): + cluster = lke_cluster url = send_request_when_resource_available( 300, cluster.cluster_dashboard_url_view @@ -62,14 +64,14 @@ def test_cluster_dashboard_url_view(create_lke_cluster): assert re.search("https://+", url) -def test_kubeconfig_delete(create_lke_cluster): - cluster = create_lke_cluster +def test_kubeconfig_delete(lke_cluster): + cluster = lke_cluster cluster.kubeconfig_delete() -def test_lke_node_view(create_lke_cluster): - cluster = create_lke_cluster +def test_lke_node_view(lke_cluster): + cluster = lke_cluster node_id = cluster.pools[0].nodes[0].id node = cluster.node_view(node_id) @@ -79,8 +81,8 @@ def test_lke_node_view(create_lke_cluster): assert node.instance_id -def test_lke_node_delete(create_lke_cluster): - cluster = create_lke_cluster +def test_lke_node_delete(lke_cluster): + cluster = lke_cluster node_id = cluster.pools[0].nodes[0].id cluster.node_delete(node_id) @@ -90,8 +92,8 @@ def test_lke_node_delete(create_lke_cluster): assert "Not found" in str(err.json) -def test_lke_node_recycle(get_client, create_lke_cluster): - cluster = get_client.load(LKECluster, create_lke_cluster.id) +def test_lke_node_recycle(test_linode_client, lke_cluster): + cluster = test_linode_client.load(LKECluster, lke_cluster.id) node = cluster.pools[0].nodes[0] node_id = cluster.pools[0].nodes[0].id @@ -109,30 +111,30 @@ def test_lke_node_recycle(get_client, create_lke_cluster): assert node.status == "ready" -def test_lke_cluster_nodes_recycle(get_client, create_lke_cluster): - cluster = create_lke_cluster +def test_lke_cluster_nodes_recycle(test_linode_client, lke_cluster): + cluster = lke_cluster send_request_when_resource_available(300, cluster.cluster_nodes_recycle) - wait_for_condition(5, 120, get_node_status, cluster, "not_ready") + wait_for_condition(5, 300, get_node_status, cluster, "not_ready") node = cluster.pools[0].nodes[0] assert node.status == "not_ready" -def test_lke_cluster_regenerate(create_lke_cluster): +def test_lke_cluster_regenerate(lke_cluster): pytest.skip( "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" ) - cluster = create_lke_cluster + cluster = lke_cluster cluster.cluster_regenerate() -def test_service_token_delete(create_lke_cluster): +def test_service_token_delete(lke_cluster): pytest.skip( "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" ) - cluster = create_lke_cluster + cluster = lke_cluster cluster.service_token_delete() diff --git a/test/integration/models/test_longview.py b/test/integration/models/test_longview.py index a137564d9..0fb7daf7f 100644 --- a/test/integration/models/test_longview.py +++ b/test/integration/models/test_longview.py @@ -7,14 +7,14 @@ @pytest.mark.smoke -def test_get_longview_client(get_client, create_longview_client): - longview = get_client.load(LongviewClient, create_longview_client.id) +def test_get_longview_client(test_linode_client, test_longview_client): + longview = test_linode_client.load(LongviewClient, test_longview_client.id) - assert longview.id == create_longview_client.id + assert longview.id == test_longview_client.id -def test_update_longview_label(get_client, create_longview_client): - longview = get_client.load(LongviewClient, create_longview_client.id) +def test_update_longview_label(test_linode_client, test_longview_client): + longview = test_linode_client.load(LongviewClient, test_longview_client.id) old_label = longview.label label = "updated_longview_label" @@ -26,8 +26,8 @@ def test_update_longview_label(get_client, create_longview_client): assert longview.label != old_label -def test_delete_client(get_client, create_longview_client): - client = get_client +def test_delete_client(test_linode_client, test_longview_client): + client = test_linode_client label = "TestSDK-longview" longview_client = client.longview.client_create(label=label) @@ -38,9 +38,9 @@ def test_delete_client(get_client, create_longview_client): assert res -def test_get_longview_subscription(get_client, create_longview_client): - subs = get_client.longview.subscriptions() - sub = get_client.load(LongviewSubscription, subs[0].id) +def test_get_longview_subscription(test_linode_client, test_longview_client): + subs = test_linode_client.longview.subscriptions() + sub = test_linode_client.load(LongviewSubscription, subs[0].id) assert "clients_included" in str(subs.first().__dict__) diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py index 970ee6880..95bc2196b 100644 --- a/test/integration/models/test_networking.py +++ b/test/integration/models/test_networking.py @@ -6,8 +6,8 @@ @pytest.mark.smoke -def test_get_networking_rules(get_client, create_firewall): - firewall = get_client.load(Firewall, create_firewall.id) +def test_get_networking_rules(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) rules = firewall.get_rules() @@ -17,8 +17,8 @@ def test_get_networking_rules(get_client, create_firewall): assert "outbound_policy" in str(rules) -def create_linode(get_client): - client = get_client +def create_linode(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = get_rand_nanosec_test_label() @@ -34,8 +34,8 @@ def create_linode(get_client): @pytest.fixture -def create_linode_for_ip_share(get_client): - linode = create_linode(get_client) +def create_linode_for_ip_share(test_linode_client): + linode = create_linode(test_linode_client) yield linode @@ -43,8 +43,8 @@ def create_linode_for_ip_share(get_client): @pytest.fixture -def create_linode_to_be_shared_with_ips(get_client): - linode = create_linode(get_client) +def create_linode_to_be_shared_with_ips(test_linode_client): + linode = create_linode(test_linode_client) yield linode @@ -53,7 +53,9 @@ def create_linode_to_be_shared_with_ips(get_client): @pytest.mark.smoke def test_ip_addresses_share( - get_client, create_linode_for_ip_share, create_linode_to_be_shared_with_ips + test_linode_client, + create_linode_for_ip_share, + create_linode_to_be_shared_with_ips, ): """ Test that you can share IP addresses with Linode. @@ -63,7 +65,7 @@ def test_ip_addresses_share( linode_instance1 = create_linode_for_ip_share linode_instance2 = create_linode_to_be_shared_with_ips - get_client.networking.ip_addresses_share( + test_linode_client.networking.ip_addresses_share( [linode_instance1.ips.ipv4.public[0]], linode_instance2.id ) @@ -75,7 +77,9 @@ def test_ip_addresses_share( @pytest.mark.smoke def test_ip_addresses_unshare( - get_client, create_linode_for_ip_share, create_linode_to_be_shared_with_ips + test_linode_client, + create_linode_for_ip_share, + create_linode_to_be_shared_with_ips, ): """ Test that you can unshare IP addresses with Linode. @@ -85,11 +89,11 @@ def test_ip_addresses_unshare( linode_instance1 = create_linode_for_ip_share linode_instance2 = create_linode_to_be_shared_with_ips - get_client.networking.ip_addresses_share( + test_linode_client.networking.ip_addresses_share( [linode_instance1.ips.ipv4.public[0]], linode_instance2.id ) # unshared the ip with instance2 - get_client.networking.ip_addresses_share([], linode_instance2.id) + test_linode_client.networking.ip_addresses_share([], linode_instance2.id) assert [] == linode_instance2.ips.ipv4.shared diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/test_nodebalancer.py index 455b88f1a..332f10214 100644 --- a/test/integration/models/test_nodebalancer.py +++ b/test/integration/models/test_nodebalancer.py @@ -7,16 +7,16 @@ @pytest.fixture(scope="session") -def create_linode_with_private_ip(get_client): - client = get_client +def linode_with_private_ip(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = "linode_with_privateip" linode_instance, password = client.linode.instance_create( - "g5-standard-4", + "g6-nanode-1", chosen_region, - image="linode/debian9", + image="linode/debian10", label=label, private_ip=True, ) @@ -27,8 +27,8 @@ def create_linode_with_private_ip(get_client): @pytest.fixture(scope="session") -def create_nb_config(get_client): - client = get_client +def create_nb_config(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] label = "nodebalancer_test" @@ -43,8 +43,8 @@ def create_nb_config(get_client): nb.delete() -def test_get_nodebalancer_config(get_client, create_nb_config): - config = get_client.load( +def test_get_nodebalancer_config(test_linode_client, create_nb_config): + config = test_linode_client.load( NodeBalancerConfig, create_nb_config.id, create_nb_config.nodebalancer_id, @@ -53,14 +53,14 @@ def test_get_nodebalancer_config(get_client, create_nb_config): @pytest.mark.smoke def test_create_nb_node( - get_client, create_nb_config, create_linode_with_private_ip + test_linode_client, create_nb_config, linode_with_private_ip ): - config = get_client.load( + config = test_linode_client.load( NodeBalancerConfig, create_nb_config.id, create_nb_config.nodebalancer_id, ) - linode = create_linode_with_private_ip + linode = linode_with_private_ip address = [a for a in linode.ipv4 if re.search("192.168.+", a)][0] node = config.node_create( "node_test", address + ":80", weight=50, mode="accept" @@ -70,16 +70,16 @@ def test_create_nb_node( assert "node_test" == node.label -def test_get_nb_node(get_client, create_nb_config): - node = get_client.load( +def test_get_nb_node(test_linode_client, create_nb_config): + node = test_linode_client.load( NodeBalancerNode, create_nb_config.nodes[0].id, (create_nb_config.id, create_nb_config.nodebalancer_id), ) -def test_update_nb_node(get_client, create_nb_config): - config = get_client.load( +def test_update_nb_node(test_linode_client, create_nb_config): + config = test_linode_client.load( NodeBalancerConfig, create_nb_config.id, create_nb_config.nodebalancer_id, @@ -90,7 +90,7 @@ def test_update_nb_node(get_client, create_nb_config): node.mode = "accept" node.save() - node_updated = get_client.load( + node_updated = test_linode_client.load( NodeBalancerNode, create_nb_config.nodes[0].id, (create_nb_config.id, create_nb_config.nodebalancer_id), @@ -101,8 +101,8 @@ def test_update_nb_node(get_client, create_nb_config): assert "accept" == node_updated.mode -def test_delete_nb_node(get_client, create_nb_config): - config = get_client.load( +def test_delete_nb_node(test_linode_client, create_nb_config): + config = test_linode_client.load( NodeBalancerConfig, create_nb_config.id, create_nb_config.nodebalancer_id, @@ -112,7 +112,7 @@ def test_delete_nb_node(get_client, create_nb_config): node.delete() with pytest.raises(ApiError) as e: - get_client.load( + test_linode_client.load( NodeBalancerNode, create_nb_config.nodes[0].id, (create_nb_config.id, create_nb_config.nodebalancer_id), diff --git a/test/integration/models/test_tag.py b/test/integration/models/test_tag.py index 42b5ec7c5..a9357a896 100644 --- a/test/integration/models/test_tag.py +++ b/test/integration/models/test_tag.py @@ -6,9 +6,9 @@ @pytest.fixture -def create_tag(get_client): +def test_tag(test_linode_client): unique_tag = get_test_label() + "_tag" - tag = get_client.tag_create(unique_tag) + tag = test_linode_client.tag_create(unique_tag) yield tag @@ -16,7 +16,7 @@ def create_tag(get_client): @pytest.mark.smoke -def test_get_tag(get_client, create_tag): - tag = get_client.load(Tag, create_tag.id) +def test_get_tag(test_linode_client, test_tag): + tag = test_linode_client.load(Tag, test_tag.id) - assert tag.id == create_tag.id + assert tag.id == test_tag.id diff --git a/test/integration/models/test_volume.py b/test/integration/models/test_volume.py index 92eb67ba3..ca63cb105 100644 --- a/test/integration/models/test_volume.py +++ b/test/integration/models/test_volume.py @@ -8,25 +8,39 @@ import pytest -from linode_api4 import LinodeClient +from linode_api4 import ApiError, LinodeClient from linode_api4.objects import Volume @pytest.fixture(scope="session") -def create_linode_for_volume(get_client): - client = get_client +def linode_for_volume(test_linode_client): + client = test_linode_client available_regions = client.regions() chosen_region = available_regions[0] - timestamp = str(int(time.time())) + timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g5-standard-4", chosen_region, image="linode/debian9", label=label + "g6-nanode-1", chosen_region, image="linode/debian10", label=label ) yield linode_instance - linode_instance.delete() + timeout = 100 # give 100s for volume to be detached before deletion + + start_time = time.time() + + while time.time() - start_time < timeout: + try: + res = linode_instance.delete() + + if res: + break + else: + time.sleep(3) + except ApiError as e: + if time.time() - start_time > timeout: + raise e def get_status(volume: Volume, status: str): @@ -36,27 +50,27 @@ def get_status(volume: Volume, status: str): @pytest.mark.smoke -def test_get_volume(get_client, create_volume): - volume = get_client.load(Volume, create_volume.id) +def test_get_volume(test_linode_client, test_volume): + volume = test_linode_client.load(Volume, test_volume.id) - assert volume.id == create_volume.id + assert volume.id == test_volume.id -def test_update_volume_tag(get_client, create_volume): - volume = create_volume +def test_update_volume_tag(test_linode_client, test_volume): + volume = test_volume tag_1 = "volume_test_tag1" tag_2 = "volume_test_tag2" volume.tags = [tag_1, tag_2] volume.save() - volume = get_client.load(Volume, create_volume.id) + volume = test_linode_client.load(Volume, test_volume.id) assert [tag_1, tag_2] == volume.tags -def test_volume_resize(get_client, create_volume): - volume = get_client.load(Volume, create_volume.id) +def test_volume_resize(test_linode_client, test_volume): + volume = test_linode_client.load(Volume, test_volume.id) wait_for_condition(10, 100, get_status, volume, "active") @@ -65,8 +79,8 @@ def test_volume_resize(get_client, create_volume): assert res -def test_volume_clone_and_delete(get_client, create_volume): - volume = get_client.load(Volume, create_volume.id) +def test_volume_clone_and_delete(test_linode_client, test_volume): + volume = test_linode_client.load(Volume, test_volume.id) label = get_test_label() wait_for_condition(10, 100, get_status, volume, "active") @@ -81,10 +95,10 @@ def test_volume_clone_and_delete(get_client, create_volume): def test_attach_volume_to_linode( - get_client, create_volume, create_linode_for_volume + test_linode_client, test_volume, linode_for_volume ): - volume = create_volume - linode = create_linode_for_volume + volume = test_volume + linode = linode_for_volume res = retry_sending_request(5, volume.attach, linode.id) @@ -92,10 +106,10 @@ def test_attach_volume_to_linode( def test_detach_volume_to_linode( - get_client, create_volume, create_linode_for_volume + test_linode_client, test_volume, linode_for_volume ): - volume = create_volume - linode = create_linode_for_volume + volume = test_volume + linode = linode_for_volume res = retry_sending_request(5, volume.detach) From 0e46856404396e31c946802e5b69850a7b2c698e Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 8 Nov 2023 17:19:23 -0500 Subject: [PATCH 151/379] Project: Virtual Private Cloud (#345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for VPCs * Drop debug statement * Clean up * Revert * oops * Add docs * Add reorder test * Add reorder test * make format * oops * Fix VPCSubnet docs * Address feedback * Use `asdict` to convert dataclass objects to dicts (#324) * test: additional integration tests for vpc (#335) ## 📝 Description - Extending test coverage for VPC ## ✔️ How to Test 1. Setup API token for alpha/beta environment and export it LINODE_TOKEN=mytoken 2. get cacert.pem (e.g. wget https://certurl.com/cacert.pem) 3. make slight modification to `def get_client()` in conftest.py e.g. `client = LinodeClient(token, base_url='https://api.dev.linode.com/v4beta', ca_path='/Users/ykim/linode/ykim/linode_api4-python/cacert.pem')` 4. run test `pytest test/integration/models/test_vpc.py` ## 📷 Preview **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Zhiwei Liang * Add `vpc_nat_1_1` to IPAddress (#342) ## 📝 Description This change is adding `vpc_nat_1_1` to the IPAddress for VPC. If a public IPv4 address is NAT 1:1 mapped to a private VPC IP, this field is returned VPC IP together with the VPC and subnet ids. Also trying to merge two commits from dev to proj/vpc to update the feature branch. The actual change is focusing on https://github.com/linode/linode_api4-python/pull/342/commits/3206ab838e088712b1e47098123ca7a1511a088b. ## ✔️ How to Test Build unit test to make sure that we can retrieve this field from IPAddress object: `tox` --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> * implementation * fixing tests * json_object * Support list deserialise in `JSONObject` * cleaning up code * make format * Replace `get_client` with `test_linode_client` (#346) * fix: Handle `null` values in `JSONObject` fields (#344) ## 📝 Description **What does this PR do and why is this change necessary?** Handle null values for `JSONObject` fields ## ✔️ How to Test **What are the steps to reproduce the issue or verify the changes?** `pytest test/integration/models/test_networking.py` --------- Co-authored-by: Lena Garber Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Co-authored-by: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Co-authored-by: Ania Misiorek Co-authored-by: Ania Misiorek <139170033+amisiorek-akamai@users.noreply.github.com> Co-authored-by: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/linode.py | 9 + linode_api4/groups/vpc.py | 83 +++++ linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 2 + linode_api4/objects/base.py | 27 +- linode_api4/objects/linode.py | 316 +++++++++++++++--- linode_api4/objects/networking.py | 17 +- linode_api4/objects/serializable.py | 102 ++++++ linode_api4/objects/vpc.py | 99 ++++++ .../linode_instances_123_configs.json | 28 +- .../linode_instances_123_configs_456789.json | 26 +- ...stances_123_configs_456789_interfaces.json | 34 ++ ...ces_123_configs_456789_interfaces_123.json | 15 + ...123_configs_456789_interfaces_123_put.json | 14 + ...ces_123_configs_456789_interfaces_321.json | 7 + ...ces_123_configs_456789_interfaces_456.json | 5 + test/fixtures/networking_ips_127.0.0.1.json | 7 +- test/fixtures/vpcs.json | 15 + test/fixtures/vpcs_123456.json | 8 + test/fixtures/vpcs_123456_subnets.json | 29 ++ test/fixtures/vpcs_123456_subnets_789.json | 22 ++ test/integration/conftest.py | 115 ++++++- test/integration/models/test_linode.py | 182 +++++++++- test/integration/models/test_networking.py | 28 +- test/integration/models/test_vpc.py | 100 ++++++ test/unit/objects/domain_test.py | 1 - test/unit/objects/linode_test.py | 229 ++++++++++++- test/unit/objects/networking_test.py | 11 + test/unit/objects/vpc_test.py | 149 +++++++++ 30 files changed, 1618 insertions(+), 67 deletions(-) create mode 100644 linode_api4/groups/vpc.py create mode 100644 linode_api4/objects/serializable.py create mode 100644 linode_api4/objects/vpc.py create mode 100644 test/fixtures/linode_instances_123_configs_456789_interfaces.json create mode 100644 test/fixtures/linode_instances_123_configs_456789_interfaces_123.json create mode 100644 test/fixtures/linode_instances_123_configs_456789_interfaces_123_put.json create mode 100644 test/fixtures/linode_instances_123_configs_456789_interfaces_321.json create mode 100644 test/fixtures/linode_instances_123_configs_456789_interfaces_456.json create mode 100644 test/fixtures/vpcs.json create mode 100644 test/fixtures/vpcs_123456.json create mode 100644 test/fixtures/vpcs_123456_subnets.json create mode 100644 test/fixtures/vpcs_123456_subnets_789.json create mode 100644 test/integration/models/test_vpc.py create mode 100644 test/unit/objects/vpc_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index f41f8cb9b..25c4858eb 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -18,3 +18,4 @@ from .support import * from .tag import * from .volume import * +from .vpc import * diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index ae575ed3c..c20a033a3 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -8,6 +8,8 @@ from linode_api4.objects import ( AuthorizedApp, Base, + ConfigInterface, + Firewall, Image, Instance, Kernel, @@ -250,6 +252,8 @@ def instance_create( The contents of this field can be built using the :any:`build_instance_metadata` method. :type metadata: dict + :param firewall: The firewall to attach this Linode to. + :type firewall: int or Firewall :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -284,6 +288,10 @@ def instance_create( ) del kwargs["backup"] + if "firewall" in kwargs: + fw = kwargs.pop("firewall") + kwargs["firewall_id"] = fw.id if isinstance(fw, Firewall) else fw + params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, @@ -292,6 +300,7 @@ def instance_create( else None, "authorized_keys": authorized_keys, } + params.update(kwargs) result = self.client.post("/linode/instances", data=params) diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py new file mode 100644 index 000000000..635e392dd --- /dev/null +++ b/linode_api4/groups/vpc.py @@ -0,0 +1,83 @@ +from typing import Any, Dict, List, Optional, Union + +from linode_api4 import VPCSubnet +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import VPC, Base, Region +from linode_api4.paginated_list import PaginatedList + + +class VPCGroup(Group): + def __call__(self, *filters) -> PaginatedList: + """ + Retrieves all of the VPCs the acting user has access to. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + vpcs = client.vpcs() + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of VPC the acting user can access. + :rtype: PaginatedList of VPC + """ + return self.client._get_and_filter(VPC, *filters) + + def create( + self, + label: str, + region: Union[Region, str], + description: Optional[str] = None, + subnets: Optional[List[Dict[str, Any]]] = None, + **kwargs, + ) -> VPC: + """ + Creates a new VPC under your Linode account. + + API Documentation: TODO + + :param label: The label of the newly created VPC. + :type label: str + :param region: The region of the newly created VPC. + :type region: Union[Region, str] + :param description: The user-defined description of this VPC. + :type description: Optional[str] + :param subnets: A list of subnets to create under this VPC. + :type subnets: List[Dict[str, Any]] + + :returns: The new VPC object. + :rtype: VPC + """ + params = { + "label": label, + "region": region.id if isinstance(region, Region) else region, + } + + if description is not None: + params["description"] = description + + if subnets is not None and len(subnets) > 0: + for subnet in subnets: + if not isinstance(subnet, dict): + raise ValueError( + f"Unsupported type for subnet: {type(subnet)}" + ) + + params["subnets"] = subnets + + params.update(kwargs) + + result = self.client.post("/vpcs", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating VPC", json=result + ) + + d = VPC(self.client, result["id"], result) + return d diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 3a6f2b8b0..a6ceb0178 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -28,6 +28,7 @@ SupportGroup, TagGroup, VolumeGroup, + VPCGroup, ) from linode_api4.objects import Image, and_ from linode_api4.objects.filtering import Filter @@ -190,6 +191,9 @@ def __init__( #: Access methods related to Images - See :any:`ImageGroup` for more information. self.images = ImageGroup(self) + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. + self.vpcs = VPCGroup(self) + #: Access methods related to Event polling - See :any:`PollingGroup` for more information. self.polling = PollingGroup(self) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index cd02fcc01..f10d4d04f 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -1,6 +1,7 @@ # isort: skip_file from .base import Base, Property, MappedObject, DATE_FORMAT, ExplicitNullValue from .dbase import DerivedBase +from .serializable import JSONObject from .filtering import and_, or_ from .region import Region from .image import Image @@ -17,4 +18,5 @@ from .object_storage import * from .lke import * from .database import * +from .vpc import * from .beta import * diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index ba613c76d..3e42e098a 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -1,6 +1,8 @@ import time from datetime import datetime, timedelta +from linode_api4.objects.serializable import JSONObject + from .filtering import FilterableMetaclass DATE_FORMAT = "%Y-%m-%dT%H:%M:%S" @@ -30,6 +32,7 @@ def __init__( id_relationship=False, slug_relationship=False, nullable=False, + json_object=None, ): """ A Property is an attribute returned from the API, and defines metadata @@ -56,6 +59,8 @@ def __init__( self.is_datetime = is_datetime self.id_relationship = id_relationship self.slug_relationship = slug_relationship + self.nullable = nullable + self.json_class = json_object class MappedObject: @@ -111,7 +116,7 @@ class Base(object, metaclass=FilterableMetaclass): properties = {} - def __init__(self, client, id, json={}): + def __init__(self, client: object, id: object, json: object = {}) -> object: self._set("_populated", False) self._set("_last_updated", datetime.min) self._set("_client", client) @@ -123,8 +128,8 @@ def __init__(self, client, id, json={}): #: be updated on access. self._set("_raw_json", None) - for prop in type(self).properties: - self._set(prop, None) + for k in type(self).properties: + self._set(k, None) self._set("id", id) if hasattr(type(self), "id_attribute"): @@ -289,7 +294,7 @@ def _serialize(self): value = getattr(self, k) - if not value: + if not v.nullable and (value is None or value == ""): continue # Let's allow explicit null values as both classes and instances @@ -305,7 +310,7 @@ def _serialize(self): for k, v in result.items(): if isinstance(v, Base): result[k] = v.id - elif isinstance(v, MappedObject): + elif isinstance(v, MappedObject) or issubclass(type(v), JSONObject): result[k] = v.dict return result @@ -376,6 +381,18 @@ def _populate(self, json): .properties[key] .slug_relationship(self._client, json[key]), ) + elif type(self).properties[key].json_class: + json_class = type(self).properties[key].json_class + json_value = json[key] + + # build JSON object + if isinstance(json_value, list): + # We need special handling for list responses + value = [json_class.from_json(v) for v in json_value] + else: + value = json_class.from_json(json_value) + + self._set(key, value) elif type(json[key]) is dict: self._set(key, MappedObject(**json[key])) elif type(json[key]) is list: diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index d928b31b6..6ddbd9fe2 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,18 +1,28 @@ import string import sys +from dataclasses import dataclass from datetime import datetime from enum import Enum from os import urandom from random import randint +from typing import Any, Dict, List, Optional, Union from urllib import parse from linode_api4 import util from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, DerivedBase, Image, Property, Region +from linode_api4.objects import ( + Base, + DerivedBase, + Image, + JSONObject, + Property, + Region, +) from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.networking import IPAddress, IPv6Range +from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation @@ -257,42 +267,133 @@ def _populate(self, json): type_class = FilterableAttribute("class") -class ConfigInterface: +@dataclass +class ConfigInterfaceIPv4(JSONObject): + vpc: str = "" + nat_1_1: str = "" + + +class NetworkInterface(DerivedBase): """ - This is a helper class used to populate 'interfaces' in the Config calss - below. + This class represents a Configuration Profile's network interface object. + NOTE: This class cannot be used for the `interfaces` attribute on Config + POST and PUT requests. + + API Documentation: TODO """ - def __init__(self, purpose, label="", ipam_address=""): + api_endpoint = ( + "/linode/instances/{instance_id}/configs/{config_id}/interfaces/{id}" + ) + derived_url_path = "interfaces" + parent_id_name = "config_id" + + properties = { + "id": Property(identifier=True), + "purpose": Property(), + "label": Property(), + "ipam_address": Property(), + "primary": Property(mutable=True), + "active": Property(), + "vpc_id": Property(id_relationship=VPC), + "subnet_id": Property(), + "ipv4": Property(mutable=True, json_object=ConfigInterfaceIPv4), + "ip_ranges": Property(mutable=True), + } + + def __init__(self, client, id, parent_id, instance_id=None, json=None): """ - Creates a new ConfigInterface + We need a special constructor here because this object's parent + has a parent itself. """ - #: The Label for the VLAN this interface is connected to. Blank for public - #: interfaces. - self.label = label + if not instance_id and not isinstance(parent_id, tuple): + raise ValueError( + "ConfigInterface must either be created with a instance_id or a tuple of " + "(config_id, instance_id) for parent_id!" + ) + + if isinstance(parent_id, tuple): + instance_id = parent_id[1] + parent_id = parent_id[0] - #: The IPAM Address this interface will bring up. Blank for public interfaces. - self.ipam_address = ipam_address + DerivedBase.__init__(self, client, id, parent_id, json=json) - #: The purpose of this interface. "public" means this interface can access - #: the internet, "vlan" means it is a VLAN interface. - self.purpose = purpose + self._set("instance_id", instance_id) def __repr__(self): - if self.purpose == "public": - return "Public Interface" - return "Interface {}; purpose: {}; ipam_address: {}".format( - self.label, self.purpose, self.ipam_address - ) + return f"Interface: {self.purpose} {self.id}" - def _serialize(self): + @property + def subnet(self) -> VPCSubnet: """ - Returns this object as a dict + Get the subnet this VPC is referencing. + + :returns: The VPCSubnet associated with this interface. + :rtype: VPCSubnet """ + return VPCSubnet(self._client, self.subnet_id, self.vpc_id) + + +@dataclass +class ConfigInterface(JSONObject): + """ + Represents a single interface in a Configuration Profile. + This class only contains data about a config interface. + If you would like to access a config interface directly, + consider using :any:`NetworkInterface`. + + API Documentation: TODO + """ + + purpose: str = "public" + + # Public/VPC-specific + primary: Optional[bool] = None + + # VLAN-specific + label: Optional[str] = None + ipam_address: Optional[str] = None + + # VPC-specific + vpc_id: Optional[int] = None + subnet_id: Optional[int] = None + ipv4: Optional[Union[ConfigInterfaceIPv4, Dict[str, Any]]] = None + ip_ranges: Optional[List[str]] = None + + # Computed + id: int = 0 + + def __repr__(self): + return f"Interface: {self.purpose}" + + def _serialize(self): + purpose_formats = { + "public": {"purpose": "public", "primary": self.primary}, + "vlan": { + "purpose": "vlan", + "label": self.label, + "ipam_address": self.ipam_address, + }, + "vpc": { + "purpose": "vpc", + "primary": self.primary, + "subnet_id": self.subnet_id, + "ipv4": self.ipv4.dict + if isinstance(self.ipv4, ConfigInterfaceIPv4) + else self.ipv4, + "ip_ranges": self.ip_ranges, + }, + } + + if self.purpose not in purpose_formats: + raise ValueError( + f"Unknown interface purpose: {self.purpose}", + ) + return { - "label": self.label, - "ipam_address": self.ipam_address, - "purpose": self.purpose, + k: v + for k, v in purpose_formats[self.purpose].items() + if v is not None } @@ -310,7 +411,7 @@ class Config(DerivedBase): properties = { "id": Property(identifier=True), "linode_id": Property(identifier=True), - "helpers": Property(), # TODO: mutable=True), + "helpers": Property(mutable=True), "created": Property(is_datetime=True), "root_device": Property(mutable=True), "kernel": Property(relationship=Kernel, mutable=True), @@ -322,14 +423,33 @@ class Config(DerivedBase): "run_level": Property(mutable=True), "virt_mode": Property(mutable=True), "memory_limit": Property(mutable=True), - "interfaces": Property(mutable=True), # gets setup in _populate below - "helpers": Property(mutable=True), + "interfaces": Property(mutable=True, json_object=ConfigInterface), } + @property + def network_interfaces(self): + """ + Returns the Network Interfaces for this Configuration Profile. + This differs from the `interfaces` field as each NetworkInterface + object is treated as its own API object. + + API Documentation: TODO + """ + + return [ + NetworkInterface( + self._client, v.id, self.id, instance_id=self.linode_id + ) + for v in self.interfaces + ] + def _populate(self, json): """ Map devices more nicely while populating. """ + if json is None or len(json) < 1: + return + # needed here to avoid circular imports from .volume import Volume # pylint: disable=import-outside-toplevel @@ -354,19 +474,6 @@ def _populate(self, json): self._set("devices", MappedObject(**devices)) - interfaces = [] - if "interfaces" in json: - interfaces = [ - ConfigInterface( - c["purpose"], - label=c["label"], - ipam_address=c["ipam_address"], - ) - for c in json["interfaces"] - ] - - self._set("interfaces", interfaces) - def _serialize(self): """ Overrides _serialize to transform interfaces into json @@ -383,6 +490,127 @@ def _serialize(self): partial["interfaces"] = interfaces return partial + def interface_create_public(self, primary=False) -> NetworkInterface: + """ + Creates a public interface for this Configuration Profile. + + API Documentation: TODO + + :param primary: Whether this interface is a primary interface. + :type primary: bool + + :returns: The newly created NetworkInterface. + :rtype: NetworkInterface + + """ + return self._interface_create({"purpose": "public", "primary": primary}) + + def interface_create_vlan( + self, label: str, ipam_address=None + ) -> NetworkInterface: + """ + Creates a VLAN interface for this Configuration Profile. + + API Documentation: TODO + + :param label: The label of the VLAN to associate this interface with. + :type label: str + :param ipam_address: The IPAM address of this interface for the associated VLAN. + :type ipam_address: str + + :returns: The newly created NetworkInterface. + :rtype: NetworkInterface + """ + params = { + "purpose": "vlan", + "label": label, + } + if ipam_address is not None: + params["ipam_address"] = ipam_address + + return self._interface_create(params) + + def interface_create_vpc( + self, + subnet: Union[int, VPCSubnet], + primary=False, + ipv4: Union[Dict[str, Any], ConfigInterfaceIPv4] = None, + ip_ranges: Optional[List[str]] = None, + ) -> NetworkInterface: + """ + Creates a VPC interface for this Configuration Profile. + + API Documentation: TODO + + :param subnet: The VPC subnet to associate this interface with. + :type subnet: int or VPCSubnet + :param primary: Whether this is a primary interface. + :type primary: bool + :param ipv4: The IPv4 configuration of the interface for the associated subnet. + :type ipv4: Dict or ConfigInterfaceIPv4 + :param ip_ranges: A list of IPs or IP ranges in the VPC subnet. + Packets to these CIDRs are routed through the + VPC network interface. + :type ip_ranges: List of str + + :returns: The newly created NetworkInterface. + :rtype: NetworkInterface + """ + params = { + "purpose": "vpc", + "subnet_id": subnet.id if isinstance(subnet, VPCSubnet) else subnet, + "primary": primary, + } + + if ipv4 is not None: + params["ipv4"] = ( + ipv4.dict if isinstance(ipv4, ConfigInterfaceIPv4) else ipv4 + ) + + if ip_ranges is not None: + params["ip_ranges"] = ip_ranges + + return self._interface_create(params) + + def interface_reorder(self, interfaces: List[Union[int, NetworkInterface]]): + """ + Change the order of the interfaces for this Configuration Profile. + + API Documentation: TODO + + :param interfaces: A list of interfaces in the desired order. + :type interfaces: List of str or NetworkInterface + """ + ids = [ + v.id if isinstance(v, NetworkInterface) else v for v in interfaces + ] + + self._client.post( + "{}/interfaces/order".format(Config.api_endpoint), + model=self, + data={"ids": ids}, + ) + self.invalidate() + + def _interface_create(self, body: Dict[str, Any]) -> NetworkInterface: + """ + The underlying ConfigInterface creation API call. + """ + result = self._client.post( + "{}/interfaces".format(Config.api_endpoint), model=self, data=body + ) + self.invalidate() + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating Interface", json=result + ) + + i = NetworkInterface( + self._client, result["id"], self.id, self.linode_id, result + ) + return i + class Instance(Base): """ @@ -790,6 +1018,7 @@ def config_create( devices=[], disks=[], volumes=[], + interfaces=[], **kwargs, ): """ @@ -863,12 +1092,19 @@ def config_create( else: raise TypeError("Disk or Volume expected!") + param_interfaces = [] + for interface in interfaces: + if isinstance(interface, ConfigInterface): + interface = interface._serialize() + param_interfaces.append(interface) + params = { "kernel": kernel.id if issubclass(type(kernel), Base) else kernel, "label": label if label else "{}_config_{}".format(self.label, len(self.configs)), "devices": device_map, + "interfaces": param_interfaces, } params.update(kwargs) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 433c318f6..1b0e46994 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,5 +1,7 @@ +from dataclasses import dataclass + from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects import Base, DerivedBase, JSONObject, Property, Region class IPv6Pool(Base): @@ -36,6 +38,18 @@ class IPv6Range(Base): } +@dataclass +class InstanceIPNAT1To1(JSONObject): + """ + InstanceIPNAT1To1 contains information about the NAT 1:1 mapping + of VPC IP together with the VPC and subnet ids. + """ + + address: str = "" + subnet_id: int = 0 + vpc_id: int = 0 + + class IPAddress(Base): """ note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. @@ -67,6 +81,7 @@ class IPAddress(Base): "rdns": Property(mutable=True), "linode_id": Property(), "region": Property(slug_relationship=Region), + "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), } @property diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py new file mode 100644 index 000000000..d0cf63282 --- /dev/null +++ b/linode_api4/objects/serializable.py @@ -0,0 +1,102 @@ +import inspect +from dataclasses import asdict, dataclass +from typing import Any, Dict, Optional, get_args, get_origin, get_type_hints + + +@dataclass +class JSONObject: + """ + A simple helper class for serializable API objects. + This is typically used for nested object values. + + This class act similarly to MappedObject but with explicit + fields and static typing. + """ + + def __init__(self): + raise NotImplementedError( + "JSONObject is not intended to be constructed directly" + ) + + # TODO: Implement __repr__ + + @staticmethod + def _try_from_json(json_value: Any, field_type: type): + """ + Determines whether a JSON dict is an instance of a field type. + """ + if inspect.isclass(field_type) and issubclass(field_type, JSONObject): + return field_type.from_json(json_value) + return json_value + + @classmethod + def _parse_attr_list(cls, json_value, field_type): + """ + Attempts to parse a list attribute with a given value and field type. + """ + + type_hint_args = get_args(field_type) + + if len(type_hint_args) < 1: + return cls._try_from_json(json_value, field_type) + + return [ + cls._try_from_json(item, type_hint_args[0]) for item in json_value + ] + + @classmethod + def _parse_attr(cls, json_value, field_type): + """ + Attempts to parse an attribute with a given value and field type. + """ + + if list in (field_type, get_origin(field_type)): + return cls._parse_attr_list(json_value, field_type) + + return cls._try_from_json(json_value, field_type) + + @classmethod + def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: + """ + Creates an instance of this class from a JSON dict. + """ + if json is None: + return None + + obj = cls() + + type_hints = get_type_hints(cls) + + for k in vars(obj): + setattr(obj, k, cls._parse_attr(json.get(k), type_hints.get(k))) + + return obj + + def _serialize(self) -> Dict[str, Any]: + """ + Serializes this object into a JSON dict. + """ + return asdict(self) + + @property + def dict(self) -> Dict[str, Any]: + """ + Alias for JSONObject._serialize() + """ + return self._serialize() + + # Various dict methods for backwards compat + def __getitem__(self, key) -> Any: + return getattr(self, key) + + def __setitem__(self, key, value): + setattr(self, key, value) + + def __iter__(self) -> Any: + return vars(self) + + def __delitem__(self, key): + setattr(self, key, None) + + def __len__(self): + return len(vars(self)) diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py new file mode 100644 index 000000000..989c542ee --- /dev/null +++ b/linode_api4/objects/vpc.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass +from typing import List, Optional + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class VPCSubnetLinodeInterface(JSONObject): + id: int = 0 + active: bool = False + + +@dataclass +class VPCSubnetLinode(JSONObject): + id: int = 0 + interfaces: List[VPCSubnetLinodeInterface] = None + + +class VPCSubnet(DerivedBase): + """ + An instance of a VPC subnet. + + API Documentation: TODO + """ + + api_endpoint = "/vpcs/{vpc_id}/subnets/{id}" + derived_url_path = "subnets" + parent_id_name = "vpc_id" + + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + "ipv4": Property(), + "linodes": Property(json_object=VPCSubnetLinode), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + } + + +class VPC(Base): + """ + An instance of a VPC. + + API Documentation: TODO + """ + + api_endpoint = "/vpcs/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + "description": Property(mutable=True), + "region": Property(slug_relationship=Region), + "subnets": Property(derived_class=VPCSubnet), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + } + + def subnet_create( + self, + label: str, + ipv4: Optional[str] = None, + **kwargs, + ) -> VPCSubnet: + """ + Creates a new Subnet object under this VPC. + + API Documentation: TODO + + :param label: The label of this subnet. + :type label: str + :param ipv4: The IPv4 range of this subnet in CIDR format. + :type ipv4: str + :param ipv6: The IPv6 range of this subnet in CIDR format. + :type ipv6: str + """ + params = { + "label": label, + } + + if ipv4 is not None: + params["ipv4"] = ipv4 + + params.update(kwargs) + + result = self._client.post( + "{}/subnets".format(VPC.api_endpoint), model=self, data=params + ) + self.invalidate() + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response creating Subnet", json=result + ) + + d = VPCSubnet(self._client, result["id"], self.id, result) + return d diff --git a/test/fixtures/linode_instances_123_configs.json b/test/fixtures/linode_instances_123_configs.json index a45ef1dd8..581b84caa 100644 --- a/test/fixtures/linode_instances_123_configs.json +++ b/test/fixtures/linode_instances_123_configs.json @@ -16,9 +16,31 @@ "id": 456789, "interfaces": [ { - "ipam_address": "0.0.0.0/24", - "label": "test-interface", - "purpose": "vlan" + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address":"10.0.0.2", + "label":"test-interface", + "purpose":"vlan" } ], "run_level": "default", diff --git a/test/fixtures/linode_instances_123_configs_456789.json b/test/fixtures/linode_instances_123_configs_456789.json index b19cba3af..93e41f86b 100644 --- a/test/fixtures/linode_instances_123_configs_456789.json +++ b/test/fixtures/linode_instances_123_configs_456789.json @@ -12,9 +12,31 @@ "created":"2014-10-07T20:04:00", "memory_limit":0, "id":456789, - "interfaces":[ + "interfaces": [ { - "ipam_address":"0.0.0.0/24", + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address":"10.0.0.2", "label":"test-interface", "purpose":"vlan" } diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces.json b/test/fixtures/linode_instances_123_configs_456789_interfaces.json new file mode 100644 index 000000000..86c709071 --- /dev/null +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address":"10.0.0.2", + "label":"test-interface", + "purpose":"vlan" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json new file mode 100644 index 000000000..d02673aeb --- /dev/null +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json @@ -0,0 +1,15 @@ +{ + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ip_ranges": [ + "10.0.0.0/24" + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_123_put.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_123_put.json new file mode 100644 index 000000000..684e26cf0 --- /dev/null +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_123_put.json @@ -0,0 +1,14 @@ +{ + "id": 123, + "purpose": "vpc", + "primary": false, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.3", + "nat_1_1": "any" + }, + "ip_ranges": [ + "10.0.0.0/24" + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_321.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_321.json new file mode 100644 index 000000000..d41133eb2 --- /dev/null +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_321.json @@ -0,0 +1,7 @@ +{ + "id": 321, + "primary": false, + "ipam_address":"10.0.0.2", + "label":"test-interface", + "purpose":"vlan" +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_456.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_456.json new file mode 100644 index 000000000..94c7bc339 --- /dev/null +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_456.json @@ -0,0 +1,5 @@ +{ + "id": 456, + "purpose": "public", + "primary": true +} \ No newline at end of file diff --git a/test/fixtures/networking_ips_127.0.0.1.json b/test/fixtures/networking_ips_127.0.0.1.json index f6567ebd5..9d3cfb449 100644 --- a/test/fixtures/networking_ips_127.0.0.1.json +++ b/test/fixtures/networking_ips_127.0.0.1.json @@ -7,5 +7,10 @@ "rdns": "test.example.org", "region": "us-east", "subnet_mask": "255.255.255.0", - "type": "ipv4" + "type": "ipv4", + "vpc_nat_1_1": { + "vpc_id": 242, + "subnet_id": 194, + "address": "139.144.244.36" + } } \ No newline at end of file diff --git a/test/fixtures/vpcs.json b/test/fixtures/vpcs.json new file mode 100644 index 000000000..9a7cc5038 --- /dev/null +++ b/test/fixtures/vpcs.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "label": "test-vpc", + "id": 123456, + "description": "A very real VPC.", + "region": "us-southeast", + "created": "2018-01-01T00:01:01", + "updated": "2018-01-01T00:01:01" + } + ], + "results": 1, + "page": 1, + "pages": 1 +} diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json new file mode 100644 index 000000000..e4c16437a --- /dev/null +++ b/test/fixtures/vpcs_123456.json @@ -0,0 +1,8 @@ +{ + "label": "test-vpc", + "id": 123456, + "description": "A very real VPC.", + "region": "us-southeast", + "created": "2018-01-01T00:01:01", + "updated": "2018-01-01T00:01:01" +} \ No newline at end of file diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json new file mode 100644 index 000000000..f846399df --- /dev/null +++ b/test/fixtures/vpcs_123456_subnets.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "label": "test-subnet", + "id": 789, + "ipv4": "10.0.0.0/24", + "linodes": [ + { + "id": 12345, + "interfaces": [ + { + "id": 678, + "active": true + }, + { + "id": 543, + "active": false + } + ] + } + ], + "created": "2018-01-01T00:01:01", + "updated": "2018-01-01T00:01:01" + } + ], + "results": 1, + "page": 1, + "pages": 1 +} \ No newline at end of file diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json new file mode 100644 index 000000000..ba6973472 --- /dev/null +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -0,0 +1,22 @@ +{ + "label": "test-subnet", + "id": 789, + "ipv4": "10.0.0.0/24", + "linodes": [ + { + "id": 12345, + "interfaces": [ + { + "id": 678, + "active": true + }, + { + "id": 543, + "active": false + } + ] + } + ], + "created": "2018-01-01T00:01:01", + "updated": "2018-01-01T00:01:01" +} \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 25bae0710..a3e4b12e4 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,13 +1,17 @@ import os +import random import time +from typing import Set import pytest from linode_api4 import ApiError from linode_api4.linode_client import LinodeClient +from linode_api4.objects import Region ENV_TOKEN_NAME = "LINODE_TOKEN" ENV_API_URL_NAME = "LINODE_API_URL" +ENV_REGION_OVERRIDE = "LINODE_TEST_REGION_OVERRIDE" ENV_API_CA_NAME = "LINODE_API_CA" RUN_LONG_TESTS = "RUN_LONG_TESTS" @@ -20,6 +24,23 @@ def get_api_url(): return os.environ.get(ENV_API_URL_NAME, "https://api.linode.com/v4beta") +def get_region(client: LinodeClient, capabilities: Set[str] = None): + region_override = os.environ.get(ENV_REGION_OVERRIDE) + + # Allow overriding the target test region + if region_override is not None: + return Region(client, region_override) + + regions = client.regions() + + if capabilities is not None: + regions = [ + v for v in regions if set(capabilities).issubset(v.capabilities) + ] + + return random.choice(regions) + + def get_api_ca_file(): result = os.environ.get(ENV_API_CA_NAME, None) return result if result != "" else None @@ -136,7 +157,9 @@ def test_volume(test_linode_client): timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp - volume = client.volume_create(label=label, region="ap-west") + volume = client.volume_create( + label=label, region=get_region(client, {"Block Storage"}) + ) yield volume @@ -177,7 +200,9 @@ def test_nodebalancer(test_linode_client): timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp - nodebalancer = client.nodebalancer_create(region="us-east", label=label) + nodebalancer = client.nodebalancer_create( + region=get_region(client), label=label + ) yield nodebalancer @@ -247,3 +272,89 @@ def test_oauth_client(test_linode_client): yield oauth_client oauth_client.delete() + + +@pytest.fixture(scope="session") +def create_vpc(test_linode_client): + client = test_linode_client + + timestamp = str(int(time.time())) + + vpc = client.vpcs.create( + "pythonsdk-" + timestamp, + get_region(test_linode_client, {"VPCs"}), + description="test description", + ) + yield vpc + + vpc.delete() + + +@pytest.fixture(scope="session") +def create_vpc_with_subnet(test_linode_client, create_vpc): + subnet = create_vpc.subnet_create("test-subnet", ipv4="10.0.0.0/24") + + yield create_vpc, subnet + + subnet.delete() + + +@pytest.fixture(scope="session") +def create_vpc_with_subnet_and_linode( + test_linode_client, create_vpc_with_subnet +): + vpc, subnet = create_vpc_with_subnet + + timestamp = str(int(time.time())) + label = "TestSDK-" + timestamp + + instance, password = test_linode_client.linode.instance_create( + "g5-standard-4", vpc.region, image="linode/debian11", label=label + ) + + yield vpc, subnet, instance, password + + instance.delete() + + +@pytest.fixture(scope="session") +def create_vpc(test_linode_client): + client = test_linode_client + + timestamp = str(int(time.time_ns() % 10**10)) + + vpc = client.vpcs.create( + "pythonsdk-" + timestamp, + get_region(test_linode_client, {"VPCs"}), + description="test description", + ) + yield vpc + + vpc.delete() + + +@pytest.fixture(scope="session") +def create_multiple_vpcs(test_linode_client): + client = test_linode_client + + timestamp = str(int(time.time_ns() % 10**10)) + + timestamp_2 = str(int(time.time_ns() % 10**10)) + + vpc_1 = client.vpcs.create( + "pythonsdk-" + timestamp, + get_region(test_linode_client, {"VPCs"}), + description="test description", + ) + + vpc_2 = client.vpcs.create( + "pythonsdk-" + timestamp_2, + get_region(test_linode_client, {"VPCs"}), + description="test description", + ) + + yield vpc_1, vpc_2 + + vpc_1.delete() + + vpc_2.delete() diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 9f6f76d65..5b68fac34 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -8,7 +8,15 @@ import pytest from linode_api4.errors import ApiError -from linode_api4.objects import Config, Disk, Image, Instance, Type +from linode_api4.objects import ( + Config, + ConfigInterface, + ConfigInterfaceIPv4, + Disk, + Image, + Instance, + Type, +) @pytest.fixture(scope="session") @@ -358,19 +366,24 @@ def test_disk_resize(): def test_config_update_interfaces(create_linode): linode = create_linode + config = linode.configs[0] + new_interfaces = [ {"purpose": "public"}, - {"purpose": "vlan", "label": "cool-vlan"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), ] - - config = linode.configs[0] - config.interfaces = new_interfaces res = config.save() + config.invalidate() assert res - assert "cool-vlan" in str(config.interfaces) + assert config.interfaces[0].purpose == "public" + assert config.interfaces[1].purpose == "vlan" + assert config.interfaces[1].label == "cool-vlan" + assert config.interfaces[1].ipam_address == "10.0.0.4/32" def test_get_config(test_linode_client, create_linode): @@ -448,3 +461,160 @@ def test_save_linode_force(test_linode_client, create_linode): linode = test_linode_client.load(Instance, linode.id) assert old_label != linode.label + + +class TestNetworkInterface: + def test_list(self, create_linode): + linode = create_linode + + config: Config = linode.configs[0] + + config.interface_create_public( + primary=True, + ) + config.interface_create_vlan( + label="testvlan", ipam_address="10.0.0.3/32" + ) + + interface = config.network_interfaces + + assert interface[0].purpose == "public" + assert interface[0].primary + assert interface[1].purpose == "vlan" + assert interface[1].label == "testvlan" + assert interface[1].ipam_address == "10.0.0.3/32" + + def test_create_public(self, create_linode): + linode = create_linode + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + interface = config.interface_create_public( + primary=True, + ) + + config.invalidate() + + assert interface.id == config.interfaces[0].id + assert interface.purpose == "public" + assert interface.primary + + def test_create_vlan(self, create_linode): + linode = create_linode + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + interface = config.interface_create_vlan( + label="testvlan", ipam_address="10.0.0.2/32" + ) + + config.invalidate() + + assert interface.id == config.interfaces[0].id + assert interface.purpose == "vlan" + assert interface.label == "testvlan" + assert interface.ipam_address == "10.0.0.2/32" + + def test_create_vpc(self, create_linode, create_vpc_with_subnet_and_linode): + vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + interface = config.interface_create_vpc( + subnet=subnet, + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ip_ranges=["10.0.0.5/32"], + ) + + config.invalidate() + + assert interface.id == config.interfaces[0].id + assert interface.subnet.id == subnet.id + assert interface.purpose == "vpc" + assert interface.ipv4.vpc == "10.0.0.2" + assert interface.ipv4.nat_1_1 == linode.ipv4[0] + assert interface.ip_ranges == ["10.0.0.5/32"] + + def test_update_vpc(self, create_linode, create_vpc_with_subnet_and_linode): + vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + interface = config.interface_create_vpc( + subnet=subnet, + primary=True, + ip_ranges=["10.0.0.5/32"], + ) + + interface.primary = False + interface.ip_ranges = ["10.0.0.6/32"] + interface.ipv4.vpc = "10.0.0.3" + interface.ipv4.nat_1_1 = "any" + + interface.save() + interface.invalidate() + config.invalidate() + + assert interface.id == config.interfaces[0].id + assert interface.subnet.id == subnet.id + assert interface.purpose == "vpc" + assert interface.ipv4.vpc == "10.0.0.3" + assert interface.ipv4.nat_1_1 == linode.ipv4[0] + assert interface.ip_ranges == ["10.0.0.6/32"] + + def test_reorder(self, create_linode): + linode = create_linode + + config: Config = linode.configs[0] + + pub_interface = config.interface_create_public( + primary=True, + ) + vlan_interface = config.interface_create_vlan( + label="testvlan", ipam_address="10.0.0.3/32" + ) + + interfaces = config.network_interfaces + interfaces.reverse() + + config.interface_reorder(interfaces) + config.invalidate() + + assert [v.id for v in config.interfaces] == [ + vlan_interface.id, + pub_interface.id, + ] + + def test_delete_interface_containing_vpc( + self, create_vpc_with_subnet_and_linode + ): + vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + interface = config.interface_create_vpc( + subnet=subnet, + primary=True, + ip_ranges=["10.0.0.8/32"], + ) + + result = interface.delete() + + # returns true when delete successful + assert result diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py index 95bc2196b..4bd994f38 100644 --- a/test/integration/models/test_networking.py +++ b/test/integration/models/test_networking.py @@ -2,7 +2,7 @@ import pytest -from linode_api4.objects import Firewall +from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress @pytest.mark.smoke @@ -23,7 +23,7 @@ def create_linode(test_linode_client): chosen_region = available_regions[0] label = get_rand_nanosec_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance, _ = client.linode.instance_create( "g6-nanode-1", chosen_region, image="linode/debian12", @@ -97,3 +97,27 @@ def test_ip_addresses_unshare( test_linode_client.networking.ip_addresses_share([], linode_instance2.id) assert [] == linode_instance2.ips.ipv4.shared + + +def test_ip_info_vpc(test_linode_client, create_vpc_with_subnet_and_linode): + vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + _ = config.interface_create_vpc( + subnet=subnet, + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ip_ranges=["10.0.0.5/32"], + ) + + config.invalidate() + + ip_info = test_linode_client.load(IPAddress, linode.ipv4[0]) + + assert ip_info.vpc_nat_1_1.address == "10.0.0.2" + assert ip_info.vpc_nat_1_1.vpc_id == vpc.id + assert ip_info.vpc_nat_1_1.subnet_id == subnet.id diff --git a/test/integration/models/test_vpc.py b/test/integration/models/test_vpc.py new file mode 100644 index 000000000..6af3380b7 --- /dev/null +++ b/test/integration/models/test_vpc.py @@ -0,0 +1,100 @@ +from test.integration.conftest import get_region + +import pytest + +from linode_api4 import VPC, ApiError, VPCSubnet + + +def test_get_vpc(test_linode_client, create_vpc): + vpc = test_linode_client.load(VPC, create_vpc.id) + test_linode_client.vpcs() + assert vpc.id == create_vpc.id + + +def test_update_vpc(test_linode_client, create_vpc): + vpc = create_vpc + new_label = create_vpc.label + "-updated" + new_desc = "updated description" + + vpc.label = new_label + vpc.description = new_desc + vpc.save() + + vpc = test_linode_client.load(VPC, create_vpc.id) + + assert vpc.label == new_label + assert vpc.description == new_desc + + +def test_get_subnet(test_linode_client, create_vpc_with_subnet): + vpc, subnet = create_vpc_with_subnet + loaded_subnet = test_linode_client.load(VPCSubnet, subnet.id, vpc.id) + + assert loaded_subnet.id == subnet.id + + +def test_update_subnet(test_linode_client, create_vpc_with_subnet): + vpc, subnet = create_vpc_with_subnet + new_label = subnet.label + "-updated" + + subnet.label = new_label + subnet.save() + + subnet = test_linode_client.load(VPCSubnet, subnet.id, vpc.id) + + assert subnet.label == new_label + + +def test_fails_create_vpc_invalid_data(test_linode_client): + with pytest.raises(ApiError) as excinfo: + test_linode_client.vpcs.create( + label="invalid_label!!", + region=get_region(test_linode_client, {"VPCs"}), + description="test description", + ) + assert excinfo.value.status == 400 + assert "Label must include only ASCII" in str(excinfo.value.json) + + +def test_get_all_vpcs(test_linode_client, create_multiple_vpcs): + vpc_1, vpc_2 = create_multiple_vpcs + + all_vpcs = test_linode_client.vpcs() + + assert str(vpc_1) in str(all_vpcs.lists) + assert str(vpc_2) in str(all_vpcs.lists) + + +def test_fails_update_vpc_invalid_data(create_vpc): + vpc = create_vpc + + invalid_label = "invalid!!" + vpc.label = invalid_label + + with pytest.raises(ApiError) as excinfo: + vpc.save() + + assert excinfo.value.status == 400 + assert "Label must include only ASCII" in str(excinfo.value.json) + + +def test_fails_create_subnet_invalid_data(create_vpc): + invalid_ipv4 = "10.0.0.0" + + with pytest.raises(ApiError) as excinfo: + create_vpc.subnet_create("test-subnet", ipv4=invalid_ipv4) + + assert excinfo.value.status == 400 + assert "ipv4 must be an IPv4 network" in str(excinfo.value.json) + + +def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): + invalid_label = "invalid_subnet_label!!" + vpc, subnet = create_vpc_with_subnet + subnet.label = invalid_label + + with pytest.raises(ApiError) as excinfo: + subnet.save() + + assert excinfo.value.status == 400 + assert "Label must include only ASCII" in str(excinfo.value.json) diff --git a/test/unit/objects/domain_test.py b/test/unit/objects/domain_test.py index 64376fb37..f67503c9c 100644 --- a/test/unit/objects/domain_test.py +++ b/test/unit/objects/domain_test.py @@ -20,7 +20,6 @@ def test_save_null_values_excluded(self): domain.type = "slave" domain.master_ips = ["127.0.0.1"] domain.save() - self.assertTrue("group" not in m.call_data.keys()) def test_zone_file_view(self): diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 2aa280fef..951bd561f 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,18 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4.objects import Config, Disk, Image, Instance, StackScript, Type +from linode_api4 import NetworkInterface +from linode_api4.objects import ( + Config, + ConfigInterface, + ConfigInterfaceIPv4, + Disk, + Image, + Instance, + StackScript, + Type, + VPCSubnet, +) class LinodeTest(ClientBaseCase): @@ -464,16 +475,17 @@ def test_update_interfaces(self): with self.mock_put("/linode/instances/123/configs/456789") as m: new_interfaces = [ - {"purpose": "public"}, - {"purpose": "vlan", "label": "cool-vlan"}, + {"purpose": "public", "primary": True}, + ConfigInterface("vlan", label="cool-vlan"), ] + expected_body = [new_interfaces[0], new_interfaces[1]._serialize()] config.interfaces = new_interfaces config.save() self.assertEqual(m.call_url, "/linode/instances/123/configs/456789") - self.assertEqual(m.call_data.get("interfaces"), new_interfaces) + self.assertEqual(m.call_data.get("interfaces"), expected_body) def test_get_config(self): json = self.client.get("/linode/instances/123/configs/456789") @@ -495,6 +507,14 @@ def test_get_config(self): self.assertEqual(config.virt_mode, "paravirt") self.assertIsNotNone(config.devices) + def test_interface_ipv4(self): + json = {"vpc": "10.0.0.1", "nat_1_1": "any"} + + ipv4 = ConfigInterfaceIPv4.from_json(json) + + self.assertEqual(ipv4.vpc, "10.0.0.1") + self.assertEqual(ipv4.nat_1_1, "any") + class StackScriptTest(ClientBaseCase): """ @@ -602,3 +622,204 @@ def test_save_force(self): with self.mock_put("linode/instances") as m: linode.save() assert m.called + + +class ConfigInterfaceTest(ClientBaseCase): + def test_list(self): + config = Config(self.client, 456789, 123) + config._api_get() + assert {v.id for v in config.interfaces} == {123, 321, 456} + assert {v.purpose for v in config.interfaces} == { + "vlan", + "vpc", + "public", + } + + def test_update(self): + config = Config(self.client, 456789, 123) + config._api_get() + config.interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ] + + with self.mock_put("linode/instances/123/configs/456789") as m: + config.save() + assert m.call_url == "/linode/instances/123/configs/456789" + assert m.call_data["interfaces"] == [ + {"purpose": "public"}, + { + "purpose": "vlan", + "label": "cool-vlan", + "ipam_address": "10.0.0.4/32", + }, + ] + + +class TestNetworkInterface(ClientBaseCase): + def test_create_interface_public(self): + config = Config(self.client, 456789, 123) + config._api_get() + + with self.mock_post( + "linode/instances/123/configs/456789/interfaces/456" + ) as m: + interface = config.interface_create_public(primary=True) + + assert m.called + assert ( + m.call_url == "/linode/instances/123/configs/456789/interfaces" + ) + assert m.method == "post" + assert m.call_data == {"purpose": "public", "primary": True} + + assert interface.id == 456 + assert interface.purpose == "public" + assert interface.primary + + def test_create_interface_vlan(self): + config = Config(self.client, 456789, 123) + config._api_get() + + with self.mock_post( + "linode/instances/123/configs/456789/interfaces/321" + ) as m: + interface = config.interface_create_vlan( + "test-interface", ipam_address="10.0.0.2/32" + ) + + assert m.called + assert ( + m.call_url == "/linode/instances/123/configs/456789/interfaces" + ) + assert m.method == "post" + assert m.call_data == { + "purpose": "vlan", + "label": "test-interface", + "ipam_address": "10.0.0.2/32", + } + + assert interface.id == 321 + assert interface.purpose == "vlan" + assert not interface.primary + assert interface.label == "test-interface" + assert interface.ipam_address == "10.0.0.2" + + def test_create_interface_vpc(self): + config = Config(self.client, 456789, 123) + config._api_get() + + with self.mock_post( + "linode/instances/123/configs/456789/interfaces/123" + ) as m: + interface = config.interface_create_vpc( + subnet=VPCSubnet(self.client, 789, 123456), + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ip_ranges=["10.0.0.0/24"], + ) + + assert m.called + assert ( + m.call_url == "/linode/instances/123/configs/456789/interfaces" + ) + assert m.method == "post" + assert m.call_data == { + "purpose": "vpc", + "primary": True, + "subnet_id": 789, + "ipv4": {"vpc": "10.0.0.4", "nat_1_1": "any"}, + "ip_ranges": ["10.0.0.0/24"], + } + + assert interface.id == 123 + assert interface.purpose == "vpc" + assert interface.primary + assert interface.vpc.id == 123456 + assert interface.subnet.id == 789 + assert interface.ipv4.vpc == "10.0.0.2" + assert interface.ipv4.nat_1_1 == "any" + assert interface.ip_ranges == ["10.0.0.0/24"] + + def test_update(self): + interface = NetworkInterface(self.client, 123, 456789, 123) + interface._api_get() + + interface.ipv4.vpc = "10.0.0.3" + interface.primary = False + interface.ip_ranges = ["10.0.0.2/32"] + + with self.mock_put( + "linode/instances/123/configs/456789/interfaces/123/put" + ) as m: + interface.save() + + assert m.called + assert ( + m.call_url + == "/linode/instances/123/configs/456789/interfaces/123" + ) + assert m.method == "put" + assert m.call_data == { + "primary": False, + "ipv4": {"vpc": "10.0.0.3", "nat_1_1": "any"}, + "ip_ranges": ["10.0.0.2/32"], + } + + def test_get_vlan(self): + interface = NetworkInterface(self.client, 321, 456789, instance_id=123) + interface._api_get() + + self.assertEqual(interface.id, 321) + self.assertEqual(interface.ipam_address, "10.0.0.2") + self.assertEqual(interface.purpose, "vlan") + self.assertEqual(interface.label, "test-interface") + + def test_get_vpc(self): + interface = NetworkInterface(self.client, 123, 456789, instance_id=123) + interface._api_get() + + self.assertEqual(interface.id, 123) + self.assertEqual(interface.purpose, "vpc") + self.assertEqual(interface.vpc.id, 123456) + self.assertEqual(interface.subnet.id, 789) + self.assertEqual(interface.ipv4.vpc, "10.0.0.2") + self.assertEqual(interface.ipv4.nat_1_1, "any") + self.assertEqual(interface.ip_ranges, ["10.0.0.0/24"]) + self.assertEqual(interface.active, True) + + def test_list(self): + config = Config(self.client, 456789, 123) + config._api_get() + interfaces = config.network_interfaces + + assert {v.id for v in interfaces} == {123, 321, 456} + assert {v.purpose for v in interfaces} == { + "vlan", + "vpc", + "public", + } + + for v in interfaces: + assert isinstance(v, NetworkInterface) + + def test_reorder(self): + config = Config(self.client, 456789, 123) + config._api_get() + interfaces = config.network_interfaces + + with self.mock_post({}) as m: + interfaces.reverse() + # Let's make sure it supports both IDs and NetworkInterfaces + interfaces[2] = interfaces[2].id + + config.interface_reorder(interfaces) + + assert ( + m.call_url + == "/linode/instances/123/configs/456789/interfaces/order" + ) + + assert m.call_data == {"ids": [321, 123, 456]} diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index 7d32ae68a..dabf1ee2b 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -72,3 +72,14 @@ def test_rdns_reset(self): self.assertEqual(m.call_url, "/networking/ips/127.0.0.1") self.assertEqual(m.call_data_raw, '{"rdns": null}') + + def test_vpc_nat_1_1(self): + """ + Tests that the vpc_nat_1_1 of an IP can be retrieved. + """ + + ip = IPAddress(self.client, "127.0.0.1") + + self.assertEqual(ip.vpc_nat_1_1.vpc_id, 242) + self.assertEqual(ip.vpc_nat_1_1.subnet_id, 194) + self.assertEqual(ip.vpc_nat_1_1.address, "139.144.244.36") diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py new file mode 100644 index 000000000..c8453ada1 --- /dev/null +++ b/test/unit/objects/vpc_test.py @@ -0,0 +1,149 @@ +import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import DATE_FORMAT, VPC, VPCSubnet +from linode_api4.objects import Volume + + +class VPCTest(ClientBaseCase): + """ + Tests methods of the VPC Group + """ + + def test_get_vpc(self): + """ + Tests that a VPC is loaded correctly by ID + """ + + vpc = VPC(self.client, 123456) + self.assertEqual(vpc._populated, False) + + self.validate_vpc_123456(vpc) + self.assertEqual(vpc._populated, True) + + def test_list_vpcs(self): + """ + Tests that you can list VPCs. + """ + + vpcs = self.client.vpcs() + + self.validate_vpc_123456(vpcs[0]) + self.assertEqual(vpcs[0]._populated, True) + + def test_create_vpc(self): + """ + Tests that you can create a VPC. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create("test-vpc", "us-southeast") + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + + def test_create_vpc_with_subnet(self): + """ + Tests that you can create a VPC. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create( + "test-vpc", + "us-southeast", + subnets=[{"label": "test-subnet", "ipv4": "10.0.0.0/24"}], + ) + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + "subnets": [ + {"label": "test-subnet", "ipv4": "10.0.0.0/24"} + ], + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + + def test_get_subnet(self): + """ + Tests that you can list VPCs. + """ + + subnet = VPCSubnet(self.client, 789, 123456) + + self.assertEqual(subnet._populated, False) + + self.validate_vpc_subnet_789(subnet) + self.assertEqual(subnet._populated, True) + self.assertEqual(subnet.linodes[0].id, 12345) + self.assertEqual(subnet.linodes[0].interfaces[0].id, 678) + self.assertEqual(len(subnet.linodes[0].interfaces), 2) + self.assertEqual(subnet.linodes[0].interfaces[1].active, False) + + def test_list_subnets(self): + """ + Tests that you can list VPCs. + """ + + subnets = self.client.vpcs()[0].subnets + + self.validate_vpc_subnet_789(subnets[0]) + + def test_create_subnet(self): + """ + Tests that you can create a subnet. + """ + + with self.mock_post("/vpcs/123456/subnets/789") as m: + vpc = VPC(self.client, 123456) + subnet = vpc.subnet_create("test-subnet", "10.0.0.0/24") + + self.assertEqual(m.call_url, "/vpcs/123456/subnets") + + self.assertEqual( + m.call_data, + { + "label": "test-subnet", + "ipv4": "10.0.0.0/24", + }, + ) + + self.validate_vpc_subnet_789(subnet) + + def validate_vpc_123456(self, vpc: VPC): + expected_dt = datetime.datetime.strptime( + "2018-01-01T00:01:01", DATE_FORMAT + ) + + self.assertEqual(vpc.label, "test-vpc") + self.assertEqual(vpc.description, "A very real VPC.") + self.assertEqual(vpc.region.id, "us-southeast") + self.assertEqual(vpc.created, expected_dt) + self.assertEqual(vpc.updated, expected_dt) + + def validate_vpc_subnet_789(self, subnet: VPCSubnet): + expected_dt = datetime.datetime.strptime( + "2018-01-01T00:01:01", DATE_FORMAT + ) + + self.assertEqual(subnet.label, "test-subnet") + self.assertEqual(subnet.ipv4, "10.0.0.0/24") + self.assertEqual(subnet.linodes[0].id, 12345) + self.assertEqual(subnet.created, expected_dt) + self.assertEqual(subnet.updated, expected_dt) From 27161d7d036fb6b4c70d41012531465ef49a47f5 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 17 Nov 2023 11:18:51 -0500 Subject: [PATCH 152/379] new: Support region availability endpoints (#349) * Add availability object * Add filter assertion * Avail docs --- linode_api4/groups/region.py | 20 + linode_api4/objects/region.py | 34 +- linode_api4/objects/serializable.py | 45 +- linode_api4/paginated_list.py | 6 + test/fixtures/regions_availability.json | 507 ++++++++++++++++++ .../regions_us-east_availability.json | 67 +++ test/unit/base.py | 7 + test/unit/objects/region_test.py | 60 ++- 8 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/regions_availability.json create mode 100644 test/fixtures/regions_us-east_availability.json diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index 4221c74a8..9ddc8fb63 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -1,5 +1,6 @@ from linode_api4.groups import Group from linode_api4.objects import Region +from linode_api4.objects.region import RegionAvailabilityEntry class RegionGroup(Group): @@ -23,3 +24,22 @@ def __call__(self, *filters): """ return self.client._get_and_filter(Region, *filters) + + def availability(self, *filters): + """ + Returns the availability of Linode plans within a Region. + + + API Documentation: https://www.linode.com/docs/api/regions/#regions-availability-list + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of entries describing the availability of a plan in a region. + :rtype: PaginatedList of RegionAvailabilityEntry + """ + + return self.client._get_and_filter( + RegionAvailabilityEntry, *filters, endpoint="/regions/availability" + ) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index a9919f94b..7f48ea846 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -1,4 +1,10 @@ -from linode_api4.objects import Base, Property +from dataclasses import dataclass +from typing import List + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.objects.base import Base, JSONObject, Property +from linode_api4.objects.filtering import FilterableAttribute +from linode_api4.objects.serializable import JSONFilterableMetaclass class Region(Base): @@ -17,3 +23,29 @@ class Region(Base): "resolvers": Property(), "label": Property(), } + + @property + def availability(self) -> List["RegionAvailabilityEntry"]: + result = self._client.get( + f"{self.api_endpoint}/availability", model=self + ) + + if result is None: + raise UnexpectedResponseError( + "Expected availability data, got None." + ) + + return [RegionAvailabilityEntry.from_json(v) for v in result] + + +@dataclass +class RegionAvailabilityEntry(JSONObject): + """ + Represents the availability of a Linode type within a region. + + API Documentation: https://www.linode.com/docs/api/regions/#region-availability-view + """ + + region: str = None + plan: str = None + available: bool = False diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index d0cf63282..e4199283b 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,10 +1,41 @@ import inspect from dataclasses import asdict, dataclass -from typing import Any, Dict, Optional, get_args, get_origin, get_type_hints +from types import SimpleNamespace +from typing import ( + Any, + ClassVar, + Dict, + Optional, + get_args, + get_origin, + get_type_hints, +) + +from linode_api4.objects.filtering import FilterableAttribute + +# Wraps the SimpleNamespace class and allows for +# SQLAlchemy-style filter generation on JSONObjects. +JSONFilterGroup = SimpleNamespace + + +class JSONFilterableMetaclass(type): + def __init__(cls, name, bases, dct): + setattr( + cls, + "filters", + JSONFilterGroup( + **{ + k: FilterableAttribute(k) + for k in cls.__annotations__.keys() + } + ), + ) + + super().__init__(name, bases, dct) @dataclass -class JSONObject: +class JSONObject(metaclass=JSONFilterableMetaclass): """ A simple helper class for serializable API objects. This is typically used for nested object values. @@ -13,6 +44,16 @@ class JSONObject: fields and static typing. """ + filters: ClassVar[JSONFilterGroup] = None + """ + A group containing FilterableAttributes used to create SQLAlchemy-style filters. + + Example usage:: + self.client.regions.availability( + RegionAvailabilityEntry.filters.plan == "premium4096.7" + ) + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" diff --git a/linode_api4/paginated_list.py b/linode_api4/paginated_list.py index 1db5bfc5d..b9421de6a 100644 --- a/linode_api4/paginated_list.py +++ b/linode_api4/paginated_list.py @@ -1,5 +1,7 @@ import math +from linode_api4.objects.serializable import JSONObject + class PaginatedList(object): """ @@ -205,6 +207,10 @@ def make_list(json_arr, client, cls, parent_id=None): for obj in json_arr: id_val = None + # Special handling for JSON objects + if issubclass(cls, JSONObject): + result.append(cls.from_json(obj)) + continue if "id" in obj: id_val = obj["id"] diff --git a/test/fixtures/regions_availability.json b/test/fixtures/regions_availability.json new file mode 100644 index 000000000..ff5122df8 --- /dev/null +++ b/test/fixtures/regions_availability.json @@ -0,0 +1,507 @@ +{ + "data": [ + { + "region": "us-central", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-central", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-central", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-central", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-central", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-central", + "plan": "premium98304.7", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-west", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-west", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-west", + "plan": "premium98304.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-southeast", + "plan": "premium98304.7", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-east", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium98304.7", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "eu-west", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "eu-west", + "plan": "premium131072.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium16384.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium262144.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium32768.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium4096.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium524288.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium65536.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium8192.7", + "available": false + }, + { + "region": "eu-west", + "plan": "premium98304.7", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "ap-south", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "ap-south", + "plan": "premium131072.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium16384.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium262144.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium32768.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium4096.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium524288.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium65536.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium8192.7", + "available": false + }, + { + "region": "ap-south", + "plan": "premium98304.7", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "eu-central", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "eu-central", + "plan": "premium131072.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium16384.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium262144.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium32768.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium4096.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium524288.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium65536.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium8192.7", + "available": false + }, + { + "region": "eu-central", + "plan": "premium98304.7", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "ap-west", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "ap-west", + "plan": "premium131072.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium16384.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium262144.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium32768.7", + "available": false + }, + { + "region": "ap-west", + "plan": "premium4096.7", + "available": false + } + ], + "page": 1, + "pages": 3, + "results": 299 +} \ No newline at end of file diff --git a/test/fixtures/regions_us-east_availability.json b/test/fixtures/regions_us-east_availability.json new file mode 100644 index 000000000..f7dc11ea2 --- /dev/null +++ b/test/fixtures/regions_us-east_availability.json @@ -0,0 +1,67 @@ +[ + { + "region": "us-east", + "plan": "gpu-rtx6000-1.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-2.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-3.1", + "available": false + }, + { + "region": "us-east", + "plan": "gpu-rtx6000-4.1", + "available": false + }, + { + "region": "us-east", + "plan": "premium131072.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium16384.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium262144.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium32768.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium4096.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium524288.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium65536.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium8192.7", + "available": false + }, + { + "region": "us-east", + "plan": "premium98304.7", + "available": false + } +] \ No newline at end of file diff --git a/test/unit/base.py b/test/unit/base.py index 1af94ff5e..e143f8f64 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -134,6 +134,13 @@ def called(self): """ return self.mock.called + @property + def call_count(self): + """ + A shortcut to check how many times the mock function was called. + """ + return self.mock.call_count + class ClientBaseCase(TestCase): def setUp(self): diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 5fd1ee7a3..9c954a3da 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,6 +1,8 @@ +import json from test.unit.base import ClientBaseCase -from linode_api4.objects import Region +from linode_api4.objects import Region, Type +from linode_api4.objects.region import RegionAvailabilityEntry class RegionTest(ClientBaseCase): @@ -20,3 +22,59 @@ def test_get_region(self): self.assertEqual(region.label, "label7") self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) + + def test_list_availability(self): + """ + Tests that region availability can be listed and filtered on. + """ + + with self.mock_get("/regions/availability") as m: + avail_entries = self.client.regions.availability( + RegionAvailabilityEntry.filters.region == "us-east", + RegionAvailabilityEntry.filters.plan == "premium4096.7", + ) + + assert len(avail_entries) > 0 + + for entry in avail_entries: + assert entry.region is not None + assert len(entry.region) > 0 + + assert entry.plan is not None + assert len(entry.plan) > 0 + + assert entry.available is not None + + # Ensure all three pages are read + assert m.call_count == 3 + assert m.mock.call_args_list[0].args[0] == "//regions/availability" + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/availability?page=2&page_size=100" + ) + assert ( + m.mock.call_args_list[2].args[0] + == "//regions/availability?page=3&page_size=100" + ) + + # Ensure the filter headers are correct + for k, call in m.mock.call_args_list: + assert json.loads(call.get("headers").get("X-Filter")) == { + "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] + } + + def test_region_availability(self): + """ + Tests that availability for a specific region can be listed and filtered on. + """ + avail_entries = Region(self.client, "us-east").availability + + for entry in avail_entries: + assert entry.region is not None + assert len(entry.region) > 0 + + assert entry.plan is not None + assert len(entry.plan) > 0 + + assert entry.available is not None From 78e2de616ef5d5afba54c9cbc009c09ed4b3cd6f Mon Sep 17 00:00:00 2001 From: mkaminsk-akamai <151915970+mkaminsk-akamai@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:57:52 +0100 Subject: [PATCH 153/379] new: Migrating legacy expire endpoint /oauth/token/expire to /oauth/revoke (#352) --- linode_api4/login_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 765dbbe2e..2b69d9eed 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -490,9 +490,10 @@ def refresh_oauth_token(self, refresh_token): def expire_token(self, token): """ - Given a token, makes a request to the authentication server to expire - it immediately. This is considered a responsible way to log out a - user. If you simply remove the session your application has for the + Given a token, makes a request to the authentication server to expire both + access token and refresh token. + This is considered a responsible way to log out a user. + If you remove only the session your application has for the user without expiring their token, the user is not _really_ logged out. :param token: The OAuth token you wish to expire @@ -504,8 +505,9 @@ def expire_token(self, token): :raises ApiError: If the expiration attempt failed. """ r = requests.post( - self._login_uri("/oauth/token/expire"), + self._login_uri("/oauth/revoke"), data={ + "token_type_hint": "access_token", "client_id": self.client_id, "client_secret": self.client_secret, "token": token, From fdcc03fb7c4223c38cf02a7a4b3b76faeb720484 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 28 Nov 2023 13:25:47 -0800 Subject: [PATCH 154/379] test: evaluate skipped tests, fix failing tests, add step in e2e workflow to mark build as failed (#351) * update test_linode.py * evaluate skipped tests, add steps in e2e workflow, fix flaky tests * lint * fix flaky tests in lke and volume * add some safety wait until functions for reorder test * update test_delete_interface_containing_vpc, test_linode_initate_migration --- .github/workflows/e2e-test-pr.yml | 17 ++- test/integration/conftest.py | 5 +- test/integration/helpers.py | 2 +- test/integration/models/test_linode.py | 199 ++++++++++++++++--------- test/integration/models/test_lke.py | 23 +-- 5 files changed, 156 insertions(+), 90 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 00391cf19..de14c9669 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -19,6 +19,8 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' && inputs.sha != '' + env: + EXIT_STATUS: 0 steps: - uses: actions-ecosystem/action-regex-match@v2 @@ -83,8 +85,8 @@ jobs: timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" status=0 - if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --junitxml="${report_filename}"; then - echo "Tests failed, but attempting to upload results anyway" + if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then + echo "EXIT_STATUS=1" >> $GITHUB_ENV fi env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} @@ -135,4 +137,13 @@ jobs: status: 'completed', conclusion: process.env.conclusion }); - return result; \ No newline at end of file + return result; + + - name: Test Execution Status Handler + run: | + if [[ "$EXIT_STATUS" != 0 ]]; then + echo "Test execution contains failure(s)" + exit $EXIT_STATUS + else + echo "Tests passed!" + fi \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a3e4b12e4..ae3550eb7 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -155,11 +155,10 @@ def test_domain(test_linode_client): def test_volume(test_linode_client): client = test_linode_client timestamp = str(time.time_ns()) + region = client.regions()[0] label = "TestSDK-" + timestamp - volume = client.volume_create( - label=label, region=get_region(client, {"Block Storage"}) - ) + volume = client.volume_create(label=label, region=region) yield volume diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 2ea66464b..c178ad4dd 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -111,7 +111,7 @@ def send_request_when_resource_available( if time.time() - start_time > timeout: raise TimeoutError( "Timeout Error: resource is not available in" - + timeout + + str(timeout) + "seconds" ) time.sleep(10) diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 5b68fac34..50497a706 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -2,6 +2,7 @@ from test.integration.helpers import ( get_test_label, retry_sending_request, + send_request_when_resource_available, wait_for_condition, ) @@ -65,6 +66,53 @@ def linode_with_volume_firewall(test_linode_client): linode_instance.delete() +@pytest.fixture(scope="session") +def linode_for_network_interface_tests(test_linode_client): + client = test_linode_client + available_regions = client.regions() + chosen_region = available_regions[0] + timestamp = str(time.time_ns()) + label = "TestSDK-" + timestamp + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", chosen_region, image="linode/debian10", label=label + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="session", autouse=True) +def linode_for_disk_tests(test_linode_client): + client = test_linode_client + available_regions = client.regions() + chosen_region = available_regions[0] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label + "_long_tests", + ) + + time.sleep(10) + + # Provisioning time + wait_for_condition(10, 300, get_status, linode_instance, "running") + + time.sleep(10) + + linode_instance.shutdown() + + wait_for_condition(10, 100, get_status, linode_instance, "offline") + + yield linode_instance + + linode_instance.delete() + + @pytest.mark.smoke @pytest.fixture def create_linode_for_long_running_tests(test_linode_client): @@ -289,22 +337,53 @@ def test_linode_volumes(linode_with_volume_firewall): assert "TestSDK" in volumes[0].label -def test_linode_disk_duplicate(test_linode_client, create_linode): - pytest.skip("Need to find out the space sizing when duplicating disks") - linode = create_linode +def wait_for_disk_status(disk: Disk, timeout): + start_time = time.time() + while True: + try: + if disk.status == "ready": + return disk.status + except ApiError: + if time.time() - start_time > timeout: + raise TimeoutError("Wait for condition timeout error") + + +@pytest.mark.dependency() +def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): + linode = linode_for_disk_tests + + disk = linode.disks[0] + + disk.resize(5000) + + # Using hard sleep instead of wait as the status shows ready when it is resizing + time.sleep(120) disk = test_linode_client.load(Disk, linode.disks[0].id, linode.id) - try: - dup_disk = disk.duplicate() - assert dup_disk.linode_id == linode.id - except ApiError as e: - assert e.status == 400 - assert "Insufficient space" in str(e.json) + assert disk.size == 5000 + + dup_disk = disk.duplicate() + + time.sleep(40) + + wait_for_disk_status(dup_disk, 120) + + assert dup_disk.linode_id == linode.id + + +@pytest.mark.dependency(depends=["test_disk_resize_and_duplicate"]) +def test_linode_create_disk(test_linode_client, linode_for_disk_tests): + linode = test_linode_client.load(Instance, linode_for_disk_tests.id) + + disk = linode.disk_create(size=500) + + wait_for_disk_status(disk, 120) + + assert disk.linode_id == linode.id def test_linode_instance_password(create_linode_for_pass_reset): - pytest.skip("Failing due to mismatched request body") linode = create_linode_for_pass_reset[0] password = create_linode_for_pass_reset[1] @@ -343,25 +422,14 @@ def test_linode_initate_migration(test_linode_client): wait_for_condition(10, 100, get_status, linode, "running") # Says it could take up to ~6 hrs for migration to fully complete - linode.initiate_migration(region="us-central") - res = linode.delete() - - assert res - - -def test_linode_create_disk(create_linode): - pytest.skip( - "Pre-requisite for the test account need to comply with this test" + send_request_when_resource_available( + 300, linode.initiate_migration, "us-central" ) - linode = create_linode - disk, gen_pass = linode.disk_create() + res = linode.delete() -def test_disk_resize(): - pytest.skip( - "Pre-requisite for the test account need to comply with this test" - ) + assert res def test_config_update_interfaces(create_linode): @@ -387,19 +455,9 @@ def test_config_update_interfaces(create_linode): def test_get_config(test_linode_client, create_linode): - pytest.skip( - "Model get method: client.load(Config, 123, 123) does not work..." - ) linode = create_linode - json = test_linode_client.get( - "linode/instances/" - + str(linode.id) - + "/configs/" - + str(linode.configs[0].id) - ) - config = Config( - test_linode_client, linode.id, linode.configs[0].id, json=json - ) + + config = test_linode_client.load(Config, linode.configs[0].id, linode.id) assert config.id == linode.configs[0].id @@ -429,18 +487,6 @@ def test_get_linode_types_overrides(test_linode_client): assert linode_type.region_prices[0].monthly >= 0 -def test_get_linode_type_by_id(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - - -def test_get_linode_type_gpu(): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - - def test_save_linode_noforce(test_linode_client, create_linode): linode = create_linode old_label = linode.label @@ -464,28 +510,29 @@ def test_save_linode_force(test_linode_client, create_linode): class TestNetworkInterface: - def test_list(self, create_linode): - linode = create_linode + def test_list(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] config.interface_create_public( primary=True, ) - config.interface_create_vlan( - label="testvlan", ipam_address="10.0.0.3/32" - ) + + label = str(time.time_ns()) + "vlabel" + + config.interface_create_vlan(label=label, ipam_address="10.0.0.3/32") interface = config.network_interfaces assert interface[0].purpose == "public" assert interface[0].primary assert interface[1].purpose == "vlan" - assert interface[1].label == "testvlan" + assert interface[1].label == label assert interface[1].ipam_address == "10.0.0.3/32" - def test_create_public(self, create_linode): - linode = create_linode + def test_create_public(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] @@ -502,8 +549,8 @@ def test_create_public(self, create_linode): assert interface.purpose == "public" assert interface.primary - def test_create_vlan(self, create_linode): - linode = create_linode + def test_create_vlan(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] @@ -521,7 +568,11 @@ def test_create_vlan(self, create_linode): assert interface.label == "testvlan" assert interface.ipam_address == "10.0.0.2/32" - def test_create_vpc(self, create_linode, create_vpc_with_subnet_and_linode): + def test_create_vpc( + self, + linode_for_network_interface_tests, + create_vpc_with_subnet_and_linode, + ): vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode config: Config = linode.configs[0] @@ -545,7 +596,11 @@ def test_create_vpc(self, create_linode, create_vpc_with_subnet_and_linode): assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] - def test_update_vpc(self, create_linode, create_vpc_with_subnet_and_linode): + def test_update_vpc( + self, + linode_for_network_interface_tests, + create_vpc_with_subnet_and_linode, + ): vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode config: Config = linode.configs[0] @@ -575,25 +630,31 @@ def test_update_vpc(self, create_linode, create_vpc_with_subnet_and_linode): assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.6/32"] - def test_reorder(self, create_linode): - linode = create_linode + def test_reorder(self, linode_for_network_interface_tests): + linode = linode_for_network_interface_tests config: Config = linode.configs[0] pub_interface = config.interface_create_public( primary=True, ) + + label = str(time.time_ns()) + "vlabel" vlan_interface = config.interface_create_vlan( - label="testvlan", ipam_address="10.0.0.3/32" + label=label, ipam_address="10.0.0.3/32" ) + send_request_when_resource_available(300, linode.shutdown) + interfaces = config.network_interfaces interfaces.reverse() - config.interface_reorder(interfaces) + send_request_when_resource_available( + 300, config.interface_reorder, interfaces + ) config.invalidate() - assert [v.id for v in config.interfaces] == [ + assert [v.id for v in config.interfaces[:2]] == [ vlan_interface.id, pub_interface.id, ] @@ -606,7 +667,11 @@ def test_delete_interface_containing_vpc( config: Config = linode.configs[0] config.interfaces = [] - config.save() + + # must power off linode before saving + send_request_when_resource_available(300, linode.shutdown) + + send_request_when_resource_available(60, config.save) interface = config.interface_create_vpc( subnet=subnet, diff --git a/test/integration/models/test_lke.py b/test/integration/models/test_lke.py index 45b1ac8a1..04b479e8e 100644 --- a/test/integration/models/test_lke.py +++ b/test/integration/models/test_lke.py @@ -45,8 +45,7 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): - pytest.skip("client.load(LKENodePool, 123, 123) does not work") - + pytest.skip("TPT-2511") cluster = lke_cluster pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) @@ -67,7 +66,9 @@ def test_cluster_dashboard_url_view(lke_cluster): def test_kubeconfig_delete(lke_cluster): cluster = lke_cluster - cluster.kubeconfig_delete() + res = send_request_when_resource_available(300, cluster.kubeconfig_delete) + + assert res is None def test_lke_node_view(lke_cluster): @@ -122,19 +123,9 @@ def test_lke_cluster_nodes_recycle(test_linode_client, lke_cluster): assert node.status == "not_ready" -def test_lke_cluster_regenerate(lke_cluster): - pytest.skip( - "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" - ) - cluster = lke_cluster - - cluster.cluster_regenerate() - - def test_service_token_delete(lke_cluster): - pytest.skip( - "Skipping reason: '400: At least one of kubeconfig or servicetoken is required.'" - ) cluster = lke_cluster - cluster.service_token_delete() + res = cluster.service_token_delete() + + assert res is None From f7fb7bd11ccf9a1b5b0759802dfbc99881339719 Mon Sep 17 00:00:00 2001 From: okokes-akamai <126059888+okokes-akamai@users.noreply.github.com> Date: Fri, 1 Dec 2023 08:37:15 +0100 Subject: [PATCH 155/379] Preserve `client.session.verify` when set by user (#353) --- linode_api4/linode_client.py | 5 ++++- test/unit/linode_client_test.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index a6ceb0178..d55958884 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -276,7 +276,10 @@ def _api_call( body = json.dumps(data) response = method( - url, headers=headers, data=body, verify=self.ca_path or True + url, + headers=headers, + data=body, + verify=self.ca_path or self.session.verify, ) warning = response.headers.get("Warning", None) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 69af304f7..09e0f9755 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -280,6 +280,28 @@ def get_mock(*params, verify=True, **kwargs): assert called + def test_custom_verify(self): + """ + If we set a custom `verify` value on our session, + we want it preserved. + """ + called = False + + self.client.session.verify = False + old_get = self.client.session.get + + def get_mock(*params, verify=True, **kwargs): + nonlocal called + called = True + assert verify is False + return old_get(*params, **kwargs) + + self.client.session.get = get_mock + + self.client.linode.instances() + + assert called + class AccountGroupTest(ClientBaseCase): """ From 8c9fc482505d9839489a93d31ad2813d12929b6e Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 4 Dec 2023 13:38:59 -0500 Subject: [PATCH 156/379] docs: Update API documentations for Beta Program (#355) --- linode_api4/groups/account.py | 4 ++-- linode_api4/groups/beta.py | 2 +- linode_api4/objects/account.py | 2 ++ linode_api4/objects/beta.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 4eeadcc11..50df1fc37 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -457,7 +457,7 @@ def enrolled_betas(self, *filters): """ Returns a list of all Beta Programs an account is enrolled in. - API doc: TBD + API doc: https://www.linode.com/docs/api/beta-programs/#enrolled-beta-programs-list :returns: a list of Beta Programs. :rtype: PaginatedList of AccountBetaProgram @@ -468,7 +468,7 @@ def join_beta_program(self, beta: Union[str, BetaProgram]): """ Enrolls an account into a beta program. - API doc: TBD + API doc: https://www.linode.com/docs/api/beta-programs/#beta-program-enroll :param beta: The object or id of a beta program to join. :type beta: BetaProgram or str diff --git a/linode_api4/groups/beta.py b/linode_api4/groups/beta.py index 18095dc03..1da34ee25 100644 --- a/linode_api4/groups/beta.py +++ b/linode_api4/groups/beta.py @@ -12,7 +12,7 @@ def betas(self, *filters): """ Returns a list of available active Beta Programs. - API Documentation: TBD + API Documentation: https://www.linode.com/docs/api/beta-programs/#beta-programs-list :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 264c6ff60..f71dd0204 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -642,6 +642,8 @@ def save(self): class AccountBetaProgram(Base): """ The details and enrollment information of a Beta program that an account is enrolled in. + + API Documentation: https://www.linode.com/docs/api/beta-programs/#enrolled-beta-program-view """ api_endpoint = "/account/betas/{id}" diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index 3124f4f15..42a3eef85 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -6,7 +6,7 @@ class BetaProgram(Base): Beta program is a new product or service that's not generally available to all customers. User with permissions can enroll into a beta program and access the functionalities. - API Documentation: TBD + API Documentation: https://www.linode.com/docs/api/beta-programs/#beta-program-view """ api_endpoint = "/betas/{id}" From ddb0b9800b5a3b71cb0e470dd20d0597c120a3cd Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 6 Dec 2023 14:34:53 -0800 Subject: [PATCH 157/379] test: add release info to xml file before uploading to obj storage (#357) * update script and workflow to include release info * update url * lint --- .github/workflows/e2e-test-pr.yml | 5 +--- test/script/add_to_xml_test_report.py | 34 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index de14c9669..ba0ff6cc4 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -91,14 +91,11 @@ jobs: env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Set release version env - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - name: Add additional information to XML report run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python test/script/add_to_xml_test_report.py \ - --branch_name "${{ env.RELEASE_VERSION }}" \ + --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" diff --git a/test/script/add_to_xml_test_report.py b/test/script/add_to_xml_test_report.py index d486028be..d978e396f 100644 --- a/test/script/add_to_xml_test_report.py +++ b/test/script/add_to_xml_test_report.py @@ -1,6 +1,35 @@ import argparse import xml.etree.ElementTree as ET +import requests + +latest_release_url = ( + "https://api.github.com/repos/linode/linode_api4-python/releases/latest" +) + + +def get_release_version(): + url = latest_release_url + + try: + response = requests.get(url) + response.raise_for_status() # Check for HTTP errors + + release_info = response.json() + version = release_info["tag_name"] + + # Remove 'v' prefix if it exists + if version.startswith("v"): + version = version[1:] + + return str(version) + + except requests.exceptions.RequestException as e: + print("Error:", e) + except KeyError: + print("Error: Unable to fetch release information from GitHub API.") + + # Parse command-line arguments parser = argparse.ArgumentParser( description="Modify XML with workflow information" @@ -8,6 +37,7 @@ parser.add_argument("--branch_name", required=True) parser.add_argument("--gha_run_id", required=True) parser.add_argument("--gha_run_number", required=True) +parser.add_argument("--release_tag", required=False) parser.add_argument( "--xmlfile", required=True ) # Added argument for XML file path @@ -29,10 +59,14 @@ gha_run_number_element = ET.Element("gha_run_number") gha_run_number_element.text = args.gha_run_number +gha_release_tag_element = ET.Element("release_tag") +gha_release_tag_element.text = get_release_version() + # Add the new elements to the root of the XML root.append(branch_name_element) root.append(gha_run_id_element) root.append(gha_run_number_element) +root.append(gha_release_tag_element) # Save the modified XML modified_xml_file_path = xml_file_path # Overwrite it From 8bf3b19a3a2f1cfd53ee7179a5dd8fc72cfe6c8c Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 7 Dec 2023 10:25:21 -0500 Subject: [PATCH 158/379] new: Support getting account availability info (#354) * add account availbility * update id to dc * replace dc with region --- linode_api4/groups/account.py | 13 +++++ linode_api4/objects/account.py | 16 ++++++ test/fixtures/account_availability.json | 51 +++++++++++++++++++ .../account_availability_us-east.json | 4 ++ test/unit/linode_client_test.py | 12 +++++ test/unit/objects/account_test.py | 18 +++++++ 6 files changed, 114 insertions(+) create mode 100644 test/fixtures/account_availability.json create mode 100644 test/fixtures/account_availability_us-east.json diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 4eeadcc11..d69a305f3 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -4,6 +4,7 @@ from linode_api4.groups import Group from linode_api4.objects import ( Account, + AccountAvailability, AccountBetaProgram, AccountSettings, BetaProgram, @@ -483,3 +484,15 @@ def join_beta_program(self, beta: Union[str, BetaProgram]): ) return True + + def availabilities(self, *filters): + """ + Returns a list of all available regions and the resources which are NOT available + to the account. + + API doc: TBD + + :returns: a list of region availability information. + :rtype: PaginatedList of AccountAvailability + """ + return self.client._get_and_filter(AccountAvailability, *filters) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 264c6ff60..c7ca8bded 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -654,3 +654,19 @@ class AccountBetaProgram(Base): "ended": Property(is_datetime=True), "enrolled": Property(is_datetime=True), } + + +class AccountAvailability(Base): + """ + The resources information in a region which are NOT available to an account. + + API doc: TBD + """ + + api_endpoint = "/account/availability/{region}" + id_attribute = "region" + + properties = { + "region": Property(identifier=True), + "unavailable": Property(), + } diff --git a/test/fixtures/account_availability.json b/test/fixtures/account_availability.json new file mode 100644 index 000000000..a09feb1db --- /dev/null +++ b/test/fixtures/account_availability.json @@ -0,0 +1,51 @@ +{ + "data": [ + { + "region": "ap-west", + "unavailable": [] + }, + { + "region": "ca-central", + "unavailable": [] + }, + { + "region": "ap-southeast", + "unavailable": [] + }, + { + "region": "us-central", + "unavailable": [] + }, + { + "region": "us-west", + "unavailable": [] + }, + { + "region": "us-southeast", + "unavailable": [] + }, + { + "region": "us-east", + "unavailable": [] + }, + { + "region": "eu-west", + "unavailable": [] + }, + { + "region": "ap-south", + "unavailable": [] + }, + { + "region": "eu-central", + "unavailable": [] + }, + { + "region": "ap-northeast", + "unavailable": [] + } + ], + "page": 1, + "pages": 1, + "results": 11 +} diff --git a/test/fixtures/account_availability_us-east.json b/test/fixtures/account_availability_us-east.json new file mode 100644 index 000000000..5bcceb526 --- /dev/null +++ b/test/fixtures/account_availability_us-east.json @@ -0,0 +1,4 @@ +{ + "region": "us-east", + "unavailable": [] +} \ No newline at end of file diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 69af304f7..77088820a 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -492,6 +492,18 @@ def test_account_transfer(self): self.assertEqual(transfer.region_transfers[0].quota, 5010) self.assertEqual(transfer.region_transfers[0].billable, 0) + def test_account_availabilities(self): + """ + Tests that account availabilities can be retrieved + """ + availabilities = self.client.account.availabilities() + + self.assertEqual(len(availabilities), 11) + availability = availabilities[0] + + self.assertEqual(availability.region, "ap-west") + self.assertEqual(availability.unavailable, []) + class BetaProgramGroupTest(ClientBaseCase): """ diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index f58aa677d..0f53240f4 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -3,6 +3,7 @@ from linode_api4.objects import ( Account, + AccountAvailability, AccountBetaProgram, AccountSettings, Database, @@ -260,3 +261,20 @@ def test_account_beta_program_api_get(self): self.assertEqual(beta.ended, datetime(2018, 1, 2, 3, 4, 5)) self.assertEqual(m.call_url, account_beta_url) + + +class AccountAvailabilityTest(ClientBaseCase): + """ + Test methods of the AccountAvailability + """ + + def test_account_availability_api_get(self): + region_id = "us-east" + account_availability_url = "/account/availability/{}".format(region_id) + + with self.mock_get(account_availability_url) as m: + availability = AccountAvailability(self.client, region_id) + self.assertEqual(availability.region, region_id) + self.assertEqual(availability.unavailable, []) + + self.assertEqual(m.call_url, account_availability_url) From e6718c0b81fb78ceda589a330de74c6714915428 Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Mon, 11 Dec 2023 16:21:18 -0500 Subject: [PATCH 159/379] new: add support for listing `nodebalancer` `firewalls` (#356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Add support for `nodebalancers/{nb_id}/firewalls` endpoint ## ✔️ How to Test **How do I run the relevant unit/integration tests?** ```bash make testunit ``` --- linode_api4/objects/nodebalancer.py | 20 ++++++- .../nodebalancers_12345_firewalls.json | 56 +++++++++++++++++++ test/unit/objects/nodebalancers_test.py | 12 ++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/nodebalancers_12345_firewalls.json diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 3f9b8e8b6..ca4228d16 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -9,7 +9,7 @@ Property, Region, ) -from linode_api4.objects.networking import IPAddress +from linode_api4.objects.networking import Firewall, IPAddress class NodeBalancerNode(DerivedBase): @@ -303,3 +303,21 @@ def statistics(self): "Unexpected response generating stats!", json=result ) return MappedObject(**result) + + def firewalls(self): + """ + View Firewall information for Firewalls associated with this NodeBalancer. + + API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-firewalls-list + + :returns: A List of Firewalls of the Linode NodeBalancer. + :rtype: List[Firewall] + """ + result = self._client.get( + "{}/firewalls".format(NodeBalancer.api_endpoint), model=self + ) + + return [ + Firewall(self._client, firewall["id"]) + for firewall in result["data"] + ] diff --git a/test/fixtures/nodebalancers_12345_firewalls.json b/test/fixtures/nodebalancers_12345_firewalls.json new file mode 100644 index 000000000..17a4a9199 --- /dev/null +++ b/test/fixtures/nodebalancers_12345_firewalls.json @@ -0,0 +1,56 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 123, + "label": "firewall123", + "rules": { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP", + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP" + }, + "status": "enabled", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index a02054aa4..24f702f7f 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -193,3 +193,15 @@ def test_statistics(self): "linode.com - balancer12345 (12345) - day (5 min avg)", ) self.assertEqual(m.call_url, statistics_url) + + def test_firewalls(self): + """ + Test that you can get the firewalls for the requested NodeBalancer. + """ + nb = NodeBalancer(self.client, 12345) + firewalls_url = "/nodebalancers/12345/firewalls" + + with self.mock_get(firewalls_url) as m: + result = nb.firewalls() + self.assertEqual(m.call_url, firewalls_url) + self.assertEqual(len(result), 1) From fd3a96e70aafa926e9e4040f2257dc3ecb79a7d7 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:52:46 -0500 Subject: [PATCH 160/379] fix: Resolve issue that prevented LKENodePools from being loaded with LinodeClient.load(...) (#359) * Fix issue that prevent LinodeClient.load(...) from working with LKENodePool * make format --- linode_api4/objects/lke.py | 7 +++++-- test/unit/objects/lke_test.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index b7edf181a..647080f44 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -81,9 +81,12 @@ def _populate(self, json): """ Parse Nodes into more useful LKENodePoolNode objects """ - if json != {}: + if json is not None and json != {}: new_nodes = [ - LKENodePoolNode(self._client, c) for c in json["nodes"] + LKENodePoolNode(self._client, c) + if not isinstance(c, dict) + else c + for c in json["nodes"] ] json["nodes"] = new_nodes diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 5c9902b38..03afc1983 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -132,3 +132,17 @@ def test_service_token_delete(self): with self.mock_delete() as m: cluster.service_token_delete() self.assertEqual(m.call_url, "/lke/clusters/18881/servicetoken") + + def test_load_node_pool(self): + """ + Tests that an LKE Node Pool can be retrieved using LinodeClient.load(...) + """ + pool = self.client.load(LKENodePool, 456, 18881) + + self.assertEqual(pool.id, 456) + self.assertEqual(pool.cluster_id, 18881) + self.assertEqual(pool.type.id, "g6-standard-4") + self.assertIsNotNone(pool.disks) + self.assertIsNotNone(pool.nodes) + self.assertIsNotNone(pool.autoscaler) + self.assertIsNotNone(pool.tags) From 38cac2bdd752c69d660fef887d35332afe5aea09 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Mon, 18 Dec 2023 09:53:48 -0800 Subject: [PATCH 161/379] test: update legacy regions with new compute regions (#358) * update legacy regions with new compute regions * update volume region for test_attach_volume_to_linode --- test/integration/conftest.py | 6 +++--- .../linode_client/test_linode_client.py | 9 +++------ test/integration/models/test_account.py | 2 +- test/integration/models/test_database.py | 4 ++-- test/integration/models/test_firewall.py | 2 +- test/integration/models/test_image.py | 2 +- test/integration/models/test_linode.py | 16 ++++++++-------- test/integration/models/test_networking.py | 2 +- test/integration/models/test_nodebalancer.py | 4 ++-- test/integration/models/test_volume.py | 2 +- 10 files changed, 23 insertions(+), 26 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index ae3550eb7..0a3344398 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -54,7 +54,7 @@ def run_long_tests(): def create_linode(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp @@ -71,7 +71,7 @@ def create_linode(test_linode_client): def create_linode_for_pass_reset(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp @@ -155,7 +155,7 @@ def test_domain(test_linode_client): def test_volume(test_linode_client): client = test_linode_client timestamp = str(time.time_ns()) - region = client.regions()[0] + region = client.regions()[4] label = "TestSDK-" + timestamp volume = client.volume_create(label=label, region=region) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 08b7e2383..60eb901b4 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -12,7 +12,7 @@ def setup_client_and_linode(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] # us-ord (Chicago) label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -33,9 +33,6 @@ def test_get_account(setup_client_and_linode): assert re.search( "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", account.email ) - assert re.search( - "^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$", account.phone - ) assert re.search("^$|[a-zA-Z0-9]+", account.address_1) assert re.search("^$|[a-zA-Z0-9]+", account.address_2) assert re.search("^$|[a-zA-Z]+", account.city) @@ -212,7 +209,7 @@ def test_get_account_settings(test_linode_client): def test_create_linode_instance_without_image(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() linode_instance = client.linode.instance_create( @@ -297,7 +294,7 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): try: cluster = client.lke.cluster_create( - "ap-west", + "us-ord", "example-cluster", {"type": "g6-standard-1", "count": 3}, invalid_version, diff --git a/test/integration/models/test_account.py b/test/integration/models/test_account.py index 9c2efc787..3d5fa2d97 100644 --- a/test/integration/models/test_account.py +++ b/test/integration/models/test_account.py @@ -64,7 +64,7 @@ def test_latest_get_event(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() linode, password = client.linode.instance_create( diff --git a/test/integration/models/test_database.py b/test/integration/models/test_database.py index 7cd41be66..0e14f5041 100644 --- a/test/integration/models/test_database.py +++ b/test/integration/models/test_database.py @@ -40,7 +40,7 @@ def test_create_sql_db(test_linode_client): ) client = test_linode_client label = get_test_label() + "-sqldb" - region = "us-east" + region = "us-ord" engine_id = get_db_engine_id(client, "mysql") dbtype = "g6-standard-1" @@ -70,7 +70,7 @@ def test_create_postgres_db(test_linode_client): ) client = test_linode_client label = get_test_label() + "-postgresqldb" - region = "us-east" + region = "us-ord" engine_id = get_db_engine_id(client, "postgresql") dbtype = "g6-standard-1" diff --git a/test/integration/models/test_firewall.py b/test/integration/models/test_firewall.py index e9e7b8bcc..7a7f58ff1 100644 --- a/test/integration/models/test_firewall.py +++ b/test/integration/models/test_firewall.py @@ -9,7 +9,7 @@ def linode_fw(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = "linode_instance_fw_device" linode_instance, password = client.linode.instance_create( diff --git a/test/integration/models/test_image.py b/test/integration/models/test_image.py index 239e65784..a622b355e 100644 --- a/test/integration/models/test_image.py +++ b/test/integration/models/test_image.py @@ -42,7 +42,7 @@ def test_image_create_upload(test_linode_client): label = get_test_label() + "_image" image = test_linode_client.image_upload( label, - "us-east", + "us-ord", BytesIO(test_image_content), description="integration test image upload", ) diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 50497a706..9429ebaf6 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -24,7 +24,7 @@ def linode_with_volume_firewall(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() rules = { @@ -70,7 +70,7 @@ def linode_with_volume_firewall(test_linode_client): def linode_for_network_interface_tests(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp @@ -87,7 +87,7 @@ def linode_for_network_interface_tests(test_linode_client): def linode_for_disk_tests(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -118,7 +118,7 @@ def linode_for_disk_tests(test_linode_client): def create_linode_for_long_running_tests(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -158,7 +158,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( @@ -208,7 +208,7 @@ def test_update_linode(create_linode): def test_delete_linode(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -413,7 +413,7 @@ def test_linode_ips(create_linode): def test_linode_initate_migration(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_test_label() + "_migration" linode, password = client.linode.instance_create( @@ -424,7 +424,7 @@ def test_linode_initate_migration(test_linode_client): # Says it could take up to ~6 hrs for migration to fully complete send_request_when_resource_available( - 300, linode.initiate_migration, "us-central" + 300, linode.initiate_migration, "us-mia" ) res = linode.delete() diff --git a/test/integration/models/test_networking.py b/test/integration/models/test_networking.py index 4bd994f38..d9f13063e 100644 --- a/test/integration/models/test_networking.py +++ b/test/integration/models/test_networking.py @@ -20,7 +20,7 @@ def test_get_networking_rules(test_linode_client, test_firewall): def create_linode(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = get_rand_nanosec_test_label() linode_instance, _ = client.linode.instance_create( diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/test_nodebalancer.py index 332f10214..6cec442b4 100644 --- a/test/integration/models/test_nodebalancer.py +++ b/test/integration/models/test_nodebalancer.py @@ -10,7 +10,7 @@ def linode_with_private_ip(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = "linode_with_privateip" linode_instance, password = client.linode.instance_create( @@ -30,7 +30,7 @@ def linode_with_private_ip(test_linode_client): def create_nb_config(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] label = "nodebalancer_test" nb = client.nodebalancer_create(region=chosen_region, label=label) diff --git a/test/integration/models/test_volume.py b/test/integration/models/test_volume.py index ca63cb105..1b351c14d 100644 --- a/test/integration/models/test_volume.py +++ b/test/integration/models/test_volume.py @@ -16,7 +16,7 @@ def linode_for_volume(test_linode_client): client = test_linode_client available_regions = client.regions() - chosen_region = available_regions[0] + chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp From 33d70542ac8e2f135b4cc1f05e8b592dd5eb9904 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 18 Dec 2023 17:41:15 -0500 Subject: [PATCH 162/379] Project: Unified Migration (#364) --------- Co-authored-by: Ania Misiorek Co-authored-by: Ania Misiorek <139170033+amisiorek-akamai@users.noreply.github.com> --- linode_api4/objects/linode.py | 31 +++++++++++++++++++++-- test/integration/helpers.py | 11 ++++---- test/integration/models/test_linode.py | 35 ++++++++++++++++++++++---- 3 files changed, 64 insertions(+), 13 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 6ddbd9fe2..a25ed082c 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -612,6 +612,11 @@ def _interface_create(self, body: Dict[str, Any]) -> NetworkInterface: return i +class MigrationType: + COLD = "cold" + WARM = "warm" + + class Instance(Base): """ A Linode Instance. @@ -948,7 +953,13 @@ def reboot(self): return False return True - def resize(self, new_type, allow_auto_disk_resize=True, **kwargs): + def resize( + self, + new_type, + allow_auto_disk_resize=True, + migration_type: MigrationType = MigrationType.COLD, + **kwargs, + ): """ Resizes a Linode you have the read_write permission to a different Type. If any actions are currently running or queued, those actions must be completed first @@ -970,6 +981,10 @@ def resize(self, new_type, allow_auto_disk_resize=True, **kwargs): data must fit within the smaller disk size. Defaults to true. :type: allow_auto_disk_resize: bool + :param migration_type: Type of migration to be used when resizing a Linode. + Customers can choose between warm and cold, the default type is cold. + :type: migration_type: str + :returns: True if the operation was successful. :rtype: bool """ @@ -979,6 +994,7 @@ def resize(self, new_type, allow_auto_disk_resize=True, **kwargs): params = { "type": new_type, "allow_auto_disk_resize": allow_auto_disk_resize, + "migration_type": migration_type, } params.update(kwargs) @@ -1438,7 +1454,12 @@ def mutate(self, allow_auto_disk_resize=True): return True - def initiate_migration(self, region=None, upgrade=None): + def initiate_migration( + self, + region=None, + upgrade=None, + migration_type: MigrationType = MigrationType.COLD, + ): """ Initiates a pending migration that is already scheduled for this Linode Instance @@ -1459,10 +1480,16 @@ def initiate_migration(self, region=None, upgrade=None): region field does not allow upgrades, then the endpoint will return a 400 error code and the migration will not be performed. :type: upgrade: bool + + :param migration_type: The type of migration that will be used for this Linode migration. + Customers can only use this param when activating a support-created migration. + Customers can choose between a cold and warm migration, cold is the default type. + :type: mirgation_type: str """ params = { "region": region.id if issubclass(type(region), Base) else region, "upgrade": upgrade, + "type": migration_type, } util.drop_null_keys(params) diff --git a/test/integration/helpers.py b/test/integration/helpers.py index c178ad4dd..3a7217a95 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -1,4 +1,3 @@ -import random import time from typing import Callable @@ -8,7 +7,7 @@ def get_test_label(): - unique_timestamp = str(int(time.time()) + random.randint(0, 1000)) + unique_timestamp = str(time.time_ns()) label = "IntTestSDK_" + unique_timestamp return label @@ -94,13 +93,13 @@ def retry_sending_request(retries: int, condition: Callable, *args) -> object: def send_request_when_resource_available( - timeout: int, func: Callable, *args + timeout: int, func: Callable, *args, **kwargs ) -> object: start_time = time.time() while True: try: - res = func(*args) + res = func(*args, **kwargs) return res except ApiError as e: if ( @@ -110,9 +109,9 @@ def send_request_when_resource_available( ): if time.time() - start_time > timeout: raise TimeoutError( - "Timeout Error: resource is not available in" + "Timeout Error: resource is not available in " + str(timeout) - + "seconds" + + " seconds" ) time.sleep(10) else: diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 9429ebaf6..9bb41a116 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -18,6 +18,7 @@ Instance, Type, ) +from linode_api4.objects.linode import MigrationType @pytest.fixture(scope="session") @@ -302,6 +303,29 @@ def test_linode_resize_with_class( assert linode.status == "running" +def test_linode_resize_with_migration_type( + create_linode_for_long_running_tests, +): + linode = create_linode_for_long_running_tests + m_type = MigrationType.WARM + + wait_for_condition(10, 100, get_status, linode, "running") + + time.sleep(5) + res = linode.resize(new_type="g6-standard-1", migration_type=m_type) + + assert res + + wait_for_condition(10, 300, get_status, linode, "resizing") + + assert linode.status == "resizing" + + # Takes about 3-5 minute to resize, sometimes longer... + wait_for_condition(30, 600, get_status, linode, "running") + + assert linode.status == "running" + + def test_linode_boot_with_config(create_linode): linode = create_linode @@ -416,15 +440,16 @@ def test_linode_initate_migration(test_linode_client): chosen_region = available_regions[4] label = get_test_label() + "_migration" - linode, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + linode, _ = client.linode.instance_create( + "g6-nanode-1", chosen_region, image="linode/debian12", label=label ) - wait_for_condition(10, 100, get_status, linode, "running") # Says it could take up to ~6 hrs for migration to fully complete - send_request_when_resource_available( - 300, linode.initiate_migration, "us-mia" + 300, + linode.initiate_migration, + region="us-mia", + migration_type=MigrationType.COLD, ) res = linode.delete() From b56ee0e808008c36b4cd7edc27a7151083cb7768 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 3 Jan 2024 10:15:10 -0800 Subject: [PATCH 163/379] test: move test upload logic to using submodule with external repository (#366) * move test upload logic to git submodule, and use it in e2e workflow * update script folder name * checkout submodule in workflow * change submodule name --- .github/workflows/e2e-test-pr.yml | 6 +- .gitmodules | 3 + test/script/add_to_xml_test_report.py | 75 ------------------------ test/script/test_report_upload_script.py | 43 -------------- tod_scripts | 1 + 5 files changed, 8 insertions(+), 120 deletions(-) create mode 100644 .gitmodules delete mode 100644 test/script/add_to_xml_test_report.py delete mode 100644 test/script/test_report_upload_script.py create mode 160000 tod_scripts diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index ba0ff6cc4..e0f9c0888 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -35,6 +35,8 @@ jobs: uses: actions/checkout@v3 with: ref: ${{ inputs.sha }} + fetch-depth: 0 + submodules: 'recursive' - name: Get the hash value of the latest commit from the PR branch uses: octokit/graphql-action@v2.x @@ -94,7 +96,7 @@ jobs: - name: Add additional information to XML report run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python test/script/add_to_xml_test_report.py \ + python tod_scripts/add_to_xml_test_report.py \ --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ @@ -103,7 +105,7 @@ jobs: - name: Upload test results run: | report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python3 test/script/test_report_upload_script.py "${report_filename}" + python3 tod_scripts/test_report_upload_script.py "${report_filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..df7dc11d7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tod_scripts"] + path = tod_scripts + url = https://github.com/linode/TOD-test-report-uploader.git diff --git a/test/script/add_to_xml_test_report.py b/test/script/add_to_xml_test_report.py deleted file mode 100644 index d978e396f..000000000 --- a/test/script/add_to_xml_test_report.py +++ /dev/null @@ -1,75 +0,0 @@ -import argparse -import xml.etree.ElementTree as ET - -import requests - -latest_release_url = ( - "https://api.github.com/repos/linode/linode_api4-python/releases/latest" -) - - -def get_release_version(): - url = latest_release_url - - try: - response = requests.get(url) - response.raise_for_status() # Check for HTTP errors - - release_info = response.json() - version = release_info["tag_name"] - - # Remove 'v' prefix if it exists - if version.startswith("v"): - version = version[1:] - - return str(version) - - except requests.exceptions.RequestException as e: - print("Error:", e) - except KeyError: - print("Error: Unable to fetch release information from GitHub API.") - - -# Parse command-line arguments -parser = argparse.ArgumentParser( - description="Modify XML with workflow information" -) -parser.add_argument("--branch_name", required=True) -parser.add_argument("--gha_run_id", required=True) -parser.add_argument("--gha_run_number", required=True) -parser.add_argument("--release_tag", required=False) -parser.add_argument( - "--xmlfile", required=True -) # Added argument for XML file path - -args = parser.parse_args() - -# Open and parse the XML file -xml_file_path = args.xmlfile -tree = ET.parse(xml_file_path) -root = tree.getroot() - -# Create new elements for the information -branch_name_element = ET.Element("branch_name") -branch_name_element.text = args.branch_name - -gha_run_id_element = ET.Element("gha_run_id") -gha_run_id_element.text = args.gha_run_id - -gha_run_number_element = ET.Element("gha_run_number") -gha_run_number_element.text = args.gha_run_number - -gha_release_tag_element = ET.Element("release_tag") -gha_release_tag_element.text = get_release_version() - -# Add the new elements to the root of the XML -root.append(branch_name_element) -root.append(gha_run_id_element) -root.append(gha_run_number_element) -root.append(gha_release_tag_element) - -# Save the modified XML -modified_xml_file_path = xml_file_path # Overwrite it -tree.write(modified_xml_file_path) - -print(f"Modified XML saved to {modified_xml_file_path}") diff --git a/test/script/test_report_upload_script.py b/test/script/test_report_upload_script.py deleted file mode 100644 index 5dd1a9e31..000000000 --- a/test/script/test_report_upload_script.py +++ /dev/null @@ -1,43 +0,0 @@ -import os -import sys - -import boto3 -from botocore.exceptions import NoCredentialsError - -ACCESS_KEY = os.environ.get("LINODE_CLI_OBJ_ACCESS_KEY") -SECRET_KEY = os.environ.get("LINODE_CLI_OBJ_SECRET_KEY") -BUCKET_NAME = "dx-test-results" - -linode_obj_config = { - "aws_access_key_id": ACCESS_KEY, - "aws_secret_access_key": SECRET_KEY, - "endpoint_url": "https://us-southeast-1.linodeobjects.com", -} - - -def upload_to_linode_object_storage(file_name): - try: - s3 = boto3.client("s3", **linode_obj_config) - - s3.upload_file(Filename=file_name, Bucket=BUCKET_NAME, Key=file_name) - - print(f"Successfully uploaded {file_name} to Linode Object Storage.") - - except NoCredentialsError: - print( - "Credentials not available. Ensure you have set your AWS credentials." - ) - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: python upload_to_linode.py ") - sys.exit(1) - - file_name = sys.argv[1] - - if not file_name: - print("Error: The provided file name is empty or invalid.") - sys.exit(1) - - upload_to_linode_object_storage(file_name) diff --git a/tod_scripts b/tod_scripts new file mode 160000 index 000000000..eec4b9955 --- /dev/null +++ b/tod_scripts @@ -0,0 +1 @@ +Subproject commit eec4b99557cef6f40e8b5b7de00357dc49fb041c From d8c3b8a72f78ab55db3d1786be4145b33f751044 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 8 Jan 2024 11:19:37 -0500 Subject: [PATCH 164/379] Fix issue loading Type using LinodeClient.load(...) (#365) --- linode_api4/objects/linode.py | 3 +- test/fixtures/linode_types_g6-nanode-1.json | 48 +++++++++++++++++++++ test/unit/objects/linode_test.py | 9 ++++ 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/linode_types_g6-nanode-1.json diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index a25ed082c..d771aaeeb 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -256,9 +256,10 @@ def _populate(self, json): """ Allows changing the name "class" in JSON to "type_class" in python """ + super()._populate(json) - if "class" in json: + if json is not None and "class" in json: setattr(self, "type_class", json["class"]) else: setattr(self, "type_class", None) diff --git a/test/fixtures/linode_types_g6-nanode-1.json b/test/fixtures/linode_types_g6-nanode-1.json new file mode 100644 index 000000000..8fc590638 --- /dev/null +++ b/test/fixtures/linode_types_g6-nanode-1.json @@ -0,0 +1,48 @@ +{ + "disk": 20480, + "memory": 1024, + "transfer": 1000, + "addons": { + "backups": { + "price": { + "hourly": 0.003, + "monthly": 2 + }, + "region_prices": [ + { + "id": "ap-west", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ] + } + }, + "class": "nanode", + "network_out": 1000, + "vcpus": 1, + "gpus": 0, + "id": "g5-nanode-1", + "label": "Linode 1024", + "price": { + "hourly": 0.0075, + "monthly": 5 + }, + "region_prices": [ + { + "id": "us-east", + "hourly": 0.02, + "monthly": 20 + }, + { + "id": "ap-northeast", + "hourly": 0.02, + "monthly": 20 + } + ], + "successor": null +} diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 951bd561f..62a043edb 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -580,6 +580,15 @@ def test_get_type_gpu(self): self.assertEqual(t.gpus, 1) self.assertEqual(t._populated, True) + def test_load_type(self): + """ + Tests that a type can be loaded using LinodeClient.load(...) + """ + + t = self.client.load(Type, "g6-nanode-1") + self.assertEqual(t._populated, True) + self.assertEqual(t.type_class, "nanode") + def test_save_noforce(self): """ Tests that a client will only save if changes are detected From d09d2eac217bdd7a883d8d9651648239c2fb1e1f Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Mon, 8 Jan 2024 08:25:17 -0800 Subject: [PATCH 165/379] test: Migrate from g5 to g6 instances, fix test failures caused by invalid labels (#367) * move test upload logic to git submodule, and use it in e2e workflow * update script folder name * migrate g5 to g6 and fix label too long error in tests * remove test script folder --- test/fixtures/linode_instances.json | 2 +- test/fixtures/linode_types.json | 6 +++--- test/fixtures/tags_something.json | 2 +- test/integration/conftest.py | 2 +- test/integration/helpers.py | 8 ++++---- test/integration/linode_client/test_linode_client.py | 2 +- test/integration/models/test_linode.py | 4 ++-- test/unit/linode_client_test.py | 10 +++++----- test/unit/objects/linode_test.py | 4 ++-- 9 files changed, 20 insertions(+), 20 deletions(-) diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index efb502e7e..3d257938d 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -8,7 +8,7 @@ "hypervisor": "kvm", "id": 123, "status": "running", - "type": "g5-standard-1", + "type": "g6-standard-1", "alerts": { "network_in": 5, "network_out": 5, diff --git a/test/fixtures/linode_types.json b/test/fixtures/linode_types.json index c864082e8..819867b79 100644 --- a/test/fixtures/linode_types.json +++ b/test/fixtures/linode_types.json @@ -31,7 +31,7 @@ "network_out": 1000, "vcpus": 1, "gpus": 0, - "id": "g5-nanode-1", + "id": "g6-nanode-1", "label": "Linode 1024", "price": { "hourly": 0.0075, @@ -127,7 +127,7 @@ "network_out": 1000, "vcpus": 1, "gpus": 0, - "id": "g5-standard-1", + "id": "g6-standard-1", "label": "Linode 2048", "price": { "hourly": 0.015, @@ -175,7 +175,7 @@ "network_out": 1000, "vcpus": 2, "gpus": 1, - "id": "g5-gpu-2", + "id": "g6-gpu-2", "label": "Linode 4096", "price": { "hourly": 0.03, diff --git a/test/fixtures/tags_something.json b/test/fixtures/tags_something.json index 67bf59097..7cce51301 100644 --- a/test/fixtures/tags_something.json +++ b/test/fixtures/tags_something.json @@ -10,7 +10,7 @@ "hypervisor": "kvm", "id": 123, "status": "running", - "type": "g5-standard-1", + "type": "g6-standard-1", "alerts": { "network_in": 5, "network_out": 5, diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 0a3344398..93cff7867 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -308,7 +308,7 @@ def create_vpc_with_subnet_and_linode( label = "TestSDK-" + timestamp instance, password = test_linode_client.linode.instance_create( - "g5-standard-4", vpc.region, image="linode/debian11", label=label + "g6-standard-1", vpc.region, image="linode/debian11", label=label ) yield vpc, subnet, instance, password diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 3a7217a95..5e9d1c441 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -7,14 +7,14 @@ def get_test_label(): - unique_timestamp = str(time.time_ns()) - label = "IntTestSDK_" + unique_timestamp + unique_timestamp = str(time.time_ns())[:-3] + label = "test_" + unique_timestamp return label def get_rand_nanosec_test_label(): - unique_timestamp = str(time.time_ns()) - label = "IntTestSDK_" + unique_timestamp + unique_timestamp = str(time.time_ns())[:-3] + label = "test_" + unique_timestamp return label diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 60eb901b4..e68f54eb2 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -276,7 +276,7 @@ def test_cluster_create_with_api_objects(test_linode_client): version = client.lke.versions()[0] region = client.regions().first() node_pools = client.lke.node_pool(node_type, 3) - label = get_test_label() + "-cluster" + label = get_test_label() cluster = client.lke.cluster_create(region, label, node_pools, version) diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 9bb41a116..f46a3fc29 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -349,7 +349,7 @@ def test_linode_firewalls(linode_with_volume_firewall): firewalls = linode.firewalls() assert len(firewalls) > 0 - assert "TestSDK" in firewalls[0].label + assert "test" in firewalls[0].label def test_linode_volumes(linode_with_volume_firewall): @@ -358,7 +358,7 @@ def test_linode_volumes(linode_with_volume_firewall): volumes = linode.volumes() assert len(volumes) > 0 - assert "TestSDK" in volumes[0].label + assert "test" in volumes[0].label def wait_for_disk_status(disk: Disk, timeout): diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 1b8924f47..3f331c9b7 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -559,7 +559,7 @@ def test_instance_create(self): """ with self.mock_post("linode/instances/123") as m: l = self.client.linode.instance_create( - "g5-standard-1", "us-east-1a" + "g6-standard-1", "us-east-1a" ) self.assertIsNotNone(l) @@ -568,7 +568,7 @@ def test_instance_create(self): self.assertEqual(m.call_url, "/linode/instances") self.assertEqual( - m.call_data, {"region": "us-east-1a", "type": "g5-standard-1"} + m.call_data, {"region": "us-east-1a", "type": "g6-standard-1"} ) def test_instance_create_with_image(self): @@ -577,7 +577,7 @@ def test_instance_create_with_image(self): """ with self.mock_post("linode/instances/123") as m: l, pw = self.client.linode.instance_create( - "g5-standard-1", "us-east-1a", image="linode/debian9" + "g6-standard-1", "us-east-1a", image="linode/debian9" ) self.assertIsNotNone(l) @@ -589,7 +589,7 @@ def test_instance_create_with_image(self): m.call_data, { "region": "us-east-1a", - "type": "g5-standard-1", + "type": "g6-standard-1", "image": "linode/debian9", "root_pass": pw, }, @@ -708,7 +708,7 @@ def test_cluster_create_with_api_objects(self): ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( - m.call_data["node_pools"], [{"type": "g5-nanode-1", "count": 3}] + m.call_data["node_pools"], [{"type": "g6-nanode-1", "count": 3}] ) self.assertEqual(m.call_data["k8s_version"], "1.19") diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 62a043edb..4121380e4 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -560,7 +560,7 @@ def test_get_type_by_id(self): """ Tests that a Linode type is loaded correctly by ID """ - t = Type(self.client, "g5-nanode-1") + t = Type(self.client, "g6-nanode-1") self.assertEqual(t._populated, False) self.assertEqual(t.vcpus, 1) @@ -574,7 +574,7 @@ def test_get_type_gpu(self): """ Tests that gpu types load up right """ - t = Type(self.client, "g5-gpu-2") + t = Type(self.client, "g6-gpu-2") self.assertEqual(t._populated, False) self.assertEqual(t.gpus, 1) From d9b2b6a0054becf64b08ef079ed8a7c67b7f1812 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 5 Feb 2024 11:25:32 -0500 Subject: [PATCH 166/379] doc: Correct `LinodeLoginClient.finish_oauth(...)` method docstring (#369) * Correct finish_oauth method docs * make format --- linode_api4/groups/linode.py | 8 +++-- linode_api4/groups/lke.py | 16 +++++---- linode_api4/groups/networking.py | 6 ++-- linode_api4/groups/obj.py | 9 +++-- linode_api4/groups/object_storage.py | 9 +++-- linode_api4/groups/volume.py | 6 ++-- linode_api4/login_client.py | 4 +-- linode_api4/objects/linode.py | 50 ++++++++++++++++------------ linode_api4/objects/lke.py | 8 +++-- linode_api4/objects/volume.py | 18 +++++----- linode_api4/util.py | 1 + 11 files changed, 80 insertions(+), 55 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index c20a033a3..11cde8022 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -295,9 +295,11 @@ def instance_create( params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, - "image": (image.id if issubclass(type(image), Base) else image) - if image - else None, + "image": ( + (image.id if issubclass(type(image), Base) else image) + if image + else None + ), "authorized_keys": authorized_keys, } diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index ac03155a7..60ec480b5 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -93,9 +93,11 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): for c in node_pools: if isinstance(c, dict): new_pool = { - "type": c["type"].id - if "type" in c and issubclass(type(c["type"]), Base) - else c.get("type"), + "type": ( + c["type"].id + if "type" in c and issubclass(type(c["type"]), Base) + else c.get("type") + ), "count": c.get("count"), } @@ -105,9 +107,11 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): "label": label, "region": region.id if issubclass(type(region), Base) else region, "node_pools": pools, - "k8s_version": kube_version.id - if issubclass(type(kube_version), Base) - else kube_version, + "k8s_version": ( + kube_version.id + if issubclass(type(kube_version), Base) + else kube_version + ), } params.update(kwargs) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index a226874ef..606435422 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -310,9 +310,9 @@ def ip_addresses_share(self, ips, linode): params = { "ips": shared_ips, - "linode_id": linode - if not isinstance(linode, Instance) - else linode.id, + "linode_id": ( + linode if not isinstance(linode, Instance) else linode.id + ), } self.client.post("/networking/ips/share", model=self, data=params) diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py index aae2c6ae2..2ca2f0b6c 100644 --- a/linode_api4/groups/obj.py +++ b/linode_api4/groups/obj.py @@ -104,9 +104,12 @@ def keys_create(self, label, bucket_access=None): { "permissions": c.get("permissions"), "bucket_name": c.get("bucket_name"), - "cluster": c.id - if "cluster" in c and issubclass(type(c["cluster"]), Base) - else c.get("cluster"), + "cluster": ( + c.id + if "cluster" in c + and issubclass(type(c["cluster"]), Base) + else c.get("cluster") + ), } for c in bucket_access ] diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index df6ba2006..1e5fca65f 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -114,9 +114,12 @@ def keys_create(self, label, bucket_access=None): { "permissions": c.get("permissions"), "bucket_name": c.get("bucket_name"), - "cluster": c.id - if "cluster" in c and issubclass(type(c["cluster"]), Base) - else c.get("cluster"), + "cluster": ( + c.id + if "cluster" in c + and issubclass(type(c["cluster"]), Base) + else c.get("cluster") + ), } for c in bucket_access ] diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 032a04c3b..b27ebf8ba 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -56,9 +56,9 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): "label": label, "size": size, "region": region.id if issubclass(type(region), Base) else region, - "linode_id": linode.id - if issubclass(type(linode), Base) - else linode, + "linode_id": ( + linode.id if issubclass(type(linode), Base) else linode + ), } params.update(kwargs) diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 2b69d9eed..1263ee49c 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -403,7 +403,7 @@ def oauth_redirect(): exchange_code = request.args.get("code") login_client = LinodeLoginClient(client_id, client_secret) - token, scopes = login_client.finish_oauth(exchange_code) + token, scopes, expiry, refresh_token = login_client.finish_oauth(exchange_code) # store the user's OAuth token in their session for later use # and mark that they are logged in. @@ -419,7 +419,7 @@ def oauth_redirect(): :returns: The new OAuth token, and a list of scopes the token has, when the token expires, and a refresh token that can generate a new valid token when this one is expired. - :rtype: tuple(str, list) + :rtype: tuple(str, list, datetime, str) :raise ApiError: If the OAuth exchange fails. """ diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index d771aaeeb..9fcdae113 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -82,9 +82,9 @@ def restore_to(self, linode, **kwargs): """ d = { - "linode_id": linode.id - if issubclass(type(linode), Base) - else linode, + "linode_id": ( + linode.id if issubclass(type(linode), Base) else linode + ), } d.update(kwargs) @@ -379,9 +379,11 @@ def _serialize(self): "purpose": "vpc", "primary": self.primary, "subnet_id": self.subnet_id, - "ipv4": self.ipv4.dict - if isinstance(self.ipv4, ConfigInterfaceIPv4) - else self.ipv4, + "ipv4": ( + self.ipv4.dict + if isinstance(self.ipv4, ConfigInterfaceIPv4) + else self.ipv4 + ), "ip_ranges": self.ip_ranges, }, } @@ -1117,9 +1119,11 @@ def config_create( params = { "kernel": kernel.id if issubclass(type(kernel), Base) else kernel, - "label": label - if label - else "{}_config_{}".format(self.label, len(self.configs)), + "label": ( + label + if label + else "{}_config_{}".format(self.label, len(self.configs)) + ), "devices": device_map, "interfaces": param_interfaces, } @@ -1190,9 +1194,11 @@ def disk_create( params = { "size": size, - "label": label - if label - else "{}_disk_{}".format(self.label, len(self.disks)), + "label": ( + label + if label + else "{}_disk_{}".format(self.label, len(self.disks)) + ), "read_only": read_only, "filesystem": filesystem, "authorized_keys": authorized_keys, @@ -1202,9 +1208,9 @@ def disk_create( if image: params.update( { - "image": image.id - if issubclass(type(image), Base) - else image, + "image": ( + image.id if issubclass(type(image), Base) else image + ), "root_pass": root_pass, } ) @@ -1629,13 +1635,15 @@ def clone( dids = [d.id if issubclass(type(d), Base) else d for d in disks] params = { - "linode_id": to_linode.id - if issubclass(type(to_linode), Base) - else to_linode, + "linode_id": ( + to_linode.id if issubclass(type(to_linode), Base) else to_linode + ), "region": region.id if issubclass(type(region), Base) else region, - "type": instance_type.id - if issubclass(type(instance_type), Base) - else instance_type, + "type": ( + instance_type.id + if issubclass(type(instance_type), Base) + else instance_type + ), "configs": cids if cids else None, "disks": dids if dids else None, "label": label, diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 647080f44..2e24b76f7 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -83,9 +83,11 @@ def _populate(self, json): """ if json is not None and json != {}: new_nodes = [ - LKENodePoolNode(self._client, c) - if not isinstance(c, dict) - else c + ( + LKENodePoolNode(self._client, c) + if not isinstance(c, dict) + else c + ) for c in json["nodes"] ] json["nodes"] = new_nodes diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index c40619187..d572c12f5 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -45,14 +45,16 @@ def attach(self, to_linode, config=None): "{}/attach".format(Volume.api_endpoint), model=self, data={ - "linode_id": to_linode.id - if issubclass(type(to_linode), Base) - else to_linode, - "config": None - if not config - else config.id - if issubclass(type(config), Base) - else config, + "linode_id": ( + to_linode.id + if issubclass(type(to_linode), Base) + else to_linode + ), + "config": ( + None + if not config + else config.id if issubclass(type(config), Base) else config + ), }, ) diff --git a/linode_api4/util.py b/linode_api4/util.py index 3a96fbc05..1ddbcc25b 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -1,6 +1,7 @@ """ Contains various utility functions. """ + from typing import Any, Dict From b468966b91735bac9a72a55037c388c3865fd9ba Mon Sep 17 00:00:00 2001 From: WingsLikeEagles <40436741+WingsLikeEagles@users.noreply.github.com> Date: Mon, 5 Feb 2024 08:34:56 -0800 Subject: [PATCH 167/379] add examples for getting the password and IPv4 (#328) * add examples for getting the password and IPv4 Add three examples: 1. Output the new_linode root password 2. Output the new_linode IPv4 address 3. Delete the new_linode * amplify warning * Update IPv4 example * Make format --------- Co-authored-by: Lena Garber Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> --- linode_api4/groups/linode.py | 21 +++++++++--- linode_api4/groups/lke.py | 16 +++++---- linode_api4/groups/networking.py | 6 ++-- linode_api4/groups/obj.py | 9 +++-- linode_api4/groups/object_storage.py | 9 +++-- linode_api4/groups/volume.py | 6 ++-- linode_api4/objects/linode.py | 50 ++++++++++++++++------------ linode_api4/objects/lke.py | 8 +++-- linode_api4/objects/volume.py | 18 +++++----- linode_api4/util.py | 1 + 10 files changed, 90 insertions(+), 54 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index c20a033a3..0c114e2ef 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -141,7 +141,9 @@ def instance_create( a :any:`Type`, a :any:`Region`, and an :any:`Image`. All three of these fields may be provided as either the ID or the appropriate object. In this mode, a root password will be generated and returned with the - new Instance object. For example:: + new Instance object. + + For example:: new_linode, password = client.linode.instance_create( "g6-standard-2", @@ -157,6 +159,15 @@ def instance_create( region, image=image) + To output the password from the above example: + print(password) + + To output the first IPv4 address of the new Linode: + print(new_linode.ipv4[0]) + + To delete the new_linode (WARNING: this immediately destroys the Linode): + new_linode.delete() + **Create an Instance from StackScript** When creating an Instance from a :any:`StackScript`, an :any:`Image` that @@ -295,9 +306,11 @@ def instance_create( params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, - "image": (image.id if issubclass(type(image), Base) else image) - if image - else None, + "image": ( + (image.id if issubclass(type(image), Base) else image) + if image + else None + ), "authorized_keys": authorized_keys, } diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index ac03155a7..60ec480b5 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -93,9 +93,11 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): for c in node_pools: if isinstance(c, dict): new_pool = { - "type": c["type"].id - if "type" in c and issubclass(type(c["type"]), Base) - else c.get("type"), + "type": ( + c["type"].id + if "type" in c and issubclass(type(c["type"]), Base) + else c.get("type") + ), "count": c.get("count"), } @@ -105,9 +107,11 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): "label": label, "region": region.id if issubclass(type(region), Base) else region, "node_pools": pools, - "k8s_version": kube_version.id - if issubclass(type(kube_version), Base) - else kube_version, + "k8s_version": ( + kube_version.id + if issubclass(type(kube_version), Base) + else kube_version + ), } params.update(kwargs) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index a226874ef..606435422 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -310,9 +310,9 @@ def ip_addresses_share(self, ips, linode): params = { "ips": shared_ips, - "linode_id": linode - if not isinstance(linode, Instance) - else linode.id, + "linode_id": ( + linode if not isinstance(linode, Instance) else linode.id + ), } self.client.post("/networking/ips/share", model=self, data=params) diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py index aae2c6ae2..2ca2f0b6c 100644 --- a/linode_api4/groups/obj.py +++ b/linode_api4/groups/obj.py @@ -104,9 +104,12 @@ def keys_create(self, label, bucket_access=None): { "permissions": c.get("permissions"), "bucket_name": c.get("bucket_name"), - "cluster": c.id - if "cluster" in c and issubclass(type(c["cluster"]), Base) - else c.get("cluster"), + "cluster": ( + c.id + if "cluster" in c + and issubclass(type(c["cluster"]), Base) + else c.get("cluster") + ), } for c in bucket_access ] diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index df6ba2006..1e5fca65f 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -114,9 +114,12 @@ def keys_create(self, label, bucket_access=None): { "permissions": c.get("permissions"), "bucket_name": c.get("bucket_name"), - "cluster": c.id - if "cluster" in c and issubclass(type(c["cluster"]), Base) - else c.get("cluster"), + "cluster": ( + c.id + if "cluster" in c + and issubclass(type(c["cluster"]), Base) + else c.get("cluster") + ), } for c in bucket_access ] diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 032a04c3b..b27ebf8ba 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -56,9 +56,9 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): "label": label, "size": size, "region": region.id if issubclass(type(region), Base) else region, - "linode_id": linode.id - if issubclass(type(linode), Base) - else linode, + "linode_id": ( + linode.id if issubclass(type(linode), Base) else linode + ), } params.update(kwargs) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index d771aaeeb..9fcdae113 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -82,9 +82,9 @@ def restore_to(self, linode, **kwargs): """ d = { - "linode_id": linode.id - if issubclass(type(linode), Base) - else linode, + "linode_id": ( + linode.id if issubclass(type(linode), Base) else linode + ), } d.update(kwargs) @@ -379,9 +379,11 @@ def _serialize(self): "purpose": "vpc", "primary": self.primary, "subnet_id": self.subnet_id, - "ipv4": self.ipv4.dict - if isinstance(self.ipv4, ConfigInterfaceIPv4) - else self.ipv4, + "ipv4": ( + self.ipv4.dict + if isinstance(self.ipv4, ConfigInterfaceIPv4) + else self.ipv4 + ), "ip_ranges": self.ip_ranges, }, } @@ -1117,9 +1119,11 @@ def config_create( params = { "kernel": kernel.id if issubclass(type(kernel), Base) else kernel, - "label": label - if label - else "{}_config_{}".format(self.label, len(self.configs)), + "label": ( + label + if label + else "{}_config_{}".format(self.label, len(self.configs)) + ), "devices": device_map, "interfaces": param_interfaces, } @@ -1190,9 +1194,11 @@ def disk_create( params = { "size": size, - "label": label - if label - else "{}_disk_{}".format(self.label, len(self.disks)), + "label": ( + label + if label + else "{}_disk_{}".format(self.label, len(self.disks)) + ), "read_only": read_only, "filesystem": filesystem, "authorized_keys": authorized_keys, @@ -1202,9 +1208,9 @@ def disk_create( if image: params.update( { - "image": image.id - if issubclass(type(image), Base) - else image, + "image": ( + image.id if issubclass(type(image), Base) else image + ), "root_pass": root_pass, } ) @@ -1629,13 +1635,15 @@ def clone( dids = [d.id if issubclass(type(d), Base) else d for d in disks] params = { - "linode_id": to_linode.id - if issubclass(type(to_linode), Base) - else to_linode, + "linode_id": ( + to_linode.id if issubclass(type(to_linode), Base) else to_linode + ), "region": region.id if issubclass(type(region), Base) else region, - "type": instance_type.id - if issubclass(type(instance_type), Base) - else instance_type, + "type": ( + instance_type.id + if issubclass(type(instance_type), Base) + else instance_type + ), "configs": cids if cids else None, "disks": dids if dids else None, "label": label, diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 647080f44..2e24b76f7 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -83,9 +83,11 @@ def _populate(self, json): """ if json is not None and json != {}: new_nodes = [ - LKENodePoolNode(self._client, c) - if not isinstance(c, dict) - else c + ( + LKENodePoolNode(self._client, c) + if not isinstance(c, dict) + else c + ) for c in json["nodes"] ] json["nodes"] = new_nodes diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index c40619187..d572c12f5 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -45,14 +45,16 @@ def attach(self, to_linode, config=None): "{}/attach".format(Volume.api_endpoint), model=self, data={ - "linode_id": to_linode.id - if issubclass(type(to_linode), Base) - else to_linode, - "config": None - if not config - else config.id - if issubclass(type(config), Base) - else config, + "linode_id": ( + to_linode.id + if issubclass(type(to_linode), Base) + else to_linode + ), + "config": ( + None + if not config + else config.id if issubclass(type(config), Base) else config + ), }, ) diff --git a/linode_api4/util.py b/linode_api4/util.py index 3a96fbc05..1ddbcc25b 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -1,6 +1,7 @@ """ Contains various utility functions. """ + from typing import Any, Dict From 0bbd8ccb3fe804412139764057c74313f1c6d917 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 13 Feb 2024 14:03:41 -0500 Subject: [PATCH 168/379] Appying PEP 621: storing metadata in `pyproject.toml` (#315) * Support PEP 621 * Replace `@PHONEY` with `.PHONY` * Remove requirements; workflows upgrade * make format * Add version file generation * Fix version var name * Fix wording Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> * Make `build` and `install` targets in Makefile rely on `create-version` * Add Python 3.12 PyPI classifier * Change unnecessary install step in publish workflow and upgrade GHA gh-action-pypi-publish --------- Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> --- .github/workflows/e2e-test-pr.yml | 14 +-- .github/workflows/lint.yml | 6 +- .github/workflows/main.yml | 8 +- .github/workflows/nightly-smoke-tests.yml | 8 +- .github/workflows/publish-pypi.yaml | 12 +- Makefile | 43 ++++--- linode_api4/version.py | 5 + pyproject.toml | 66 ++++++++++- requirements-dev.txt | 13 --- requirements.txt | 4 - setup.py | 133 +--------------------- tox.ini | 2 +- 12 files changed, 116 insertions(+), 198 deletions(-) create mode 100644 linode_api4/version.py delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index e0f9c0888..ac40302c6 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -32,7 +32,7 @@ jobs: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ inputs.sha }} fetch-depth: 0 @@ -63,22 +63,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update system packages - run: sudo apt-get update -y - - - name: Install system deps - run: sudo apt-get install -y build-essential - - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install -U setuptools wheel boto3 certifi - name: Install Python SDK - run: make install + run: make dev-install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f55a491e..9f9391533 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,15 +10,15 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: setup python 3 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: install dependencies - run: make requirements + run: make dev-install - name: run linter run: make lint \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0813c2581..ee290360c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8','3.9','3.10','3.11'] + python-version: ['3.8','3.9','3.10','3.11', '3.12'] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Run tests run: | - pip install .[test] + pip install ".[test]" tox diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index f2934e604..b1a0fcbfd 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -11,20 +11,20 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: dev - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install -U setuptools wheel boto3 certifi - name: Install Python SDK - run: make install + run: make dev-install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index c8e47f755..bca202209 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -8,16 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 - - - name: Update system packages - run: sudo apt-get update -y - - - name: Install make - run: sudo apt-get install -y build-essential + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -30,6 +24,6 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@a56da0b891b3dc519c7ee3284aff1fad93cc8598 # pin@release/v1.8.6 + uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf # pin@release/v1.8.11 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/Makefile b/Makefile index 7482f9073..1276e7639 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ INTEGRATION_TEST_PATH := TEST_CASE_COMMAND := MODEL_COMMAND := +LINODE_SDK_VERSION ?= "0.0.0.dev" +VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n +VERSION_FILE := ./linode_api4/version.py + ifdef TEST_CASE TEST_CASE_COMMAND = -k $(TEST_CASE) endif @@ -12,45 +16,48 @@ ifdef TEST_MODEL MODEL_COMMAND = models/$(TEST_MODEL) endif -@PHONEY: clean +.PHONY: clean clean: mkdir -p dist rm -r dist rm -f baked_version -@PHONEY: build -build: clean +.PHONY: build +build: clean create-version $(PYTHON) -m build --wheel --sdist +.PHONY: create-version +create-version: + @echo "${VERSION_MODULE_DOCSTRING}__version__ = \"${LINODE_SDK_VERSION}\"" > $(VERSION_FILE) -@PHONEY: release +.PHONY: release release: build $(PYTHON) -m twine upload dist/* -@PHONEY: install -install: clean requirements - $(PYTHON) -m pip install . +.PHONY: dev-install +dev-install: clean + $(PYTHON) -m pip install -e ".[dev]" -@PHONEY: requirements -requirements: - $(PYTHON) -m pip install -r requirements.txt -r requirements-dev.txt +.PHONY: install +install: clean create-version + $(PYTHON) -m pip install . -@PHONEY: black +.PHONY: black black: $(PYTHON) -m black linode_api4 test -@PHONEY: isort +.PHONY: isort isort: $(PYTHON) -m isort linode_api4 test -@PHONEY: autoflake +.PHONY: autoflake autoflake: $(PYTHON) -m autoflake linode_api4 test -@PHONEY: format +.PHONY: format format: black isort autoflake -@PHONEY: lint +.PHONY: lint lint: build $(PYTHON) -m isort --check-only linode_api4 test $(PYTHON) -m autoflake --check linode_api4 test @@ -58,14 +65,14 @@ lint: build $(PYTHON) -m pylint linode_api4 $(PYTHON) -m twine check dist/* -@PHONEY: testint +.PHONY: testint testint: $(PYTHON) -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} -@PHONEY: testunit +.PHONY: testunit testunit: $(PYTHON) -m pytest test/unit -@PHONEY: smoketest +.PHONY: smoketest smoketest: $(PYTHON) -m pytest -m smoke test/integration --disable-warnings \ No newline at end of file diff --git a/linode_api4/version.py b/linode_api4/version.py new file mode 100644 index 000000000..04065ecda --- /dev/null +++ b/linode_api4/version.py @@ -0,0 +1,5 @@ +""" +The version of this linode_api4 package. +""" + +__version__ = "0.0.0.dev" diff --git a/pyproject.toml b/pyproject.toml index dc391f2ca..787cbc46a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,71 @@ [build-system] -requires = ["setuptools"] +requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" + +[project] +name = "linode_api4" +authors = [{ name = "Linode", email = "developers@linode.com" }] +description = "The official Python SDK for Linode API v4" +readme = "README.rst" +requires-python = ">=3.8" +keywords = [ + "akamai", + "Akamai Connected Cloud", + "linode", + "cloud", + "SDK", + "Linode APIv4", +] +license = { text = "BSD-3-Clause" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["requests", "polling"] +dynamic = ["version"] + +[project.optional-dependencies] +test = ["tox>=4.4.0"] + +dev = [ + "tox>=4.4.0", + "mock>=5.0.0", + "pytest>=7.3.1", + "httpretty>=1.1.4", + "black>=23.1.0", + "isort>=5.12.0", + "autoflake>=2.0.1", + "pylint", + "twine>=4.0.2", + "build>=0.10.0", + "Sphinx>=6.0.0", + "sphinx-autobuild>=2021.3.14", + "sphinxcontrib-fulltoc>=1.2.0", + "build>=0.10.0", + "twine>=4.0.2", +] + +[project.urls] +Homepage = "https://github.com/linode/linode_api4-python" +Documentation = "https://linode-api4.readthedocs.io/" +Repository = "https://github.com/linode/linode_api4-python.git" + +[tool.setuptools.dynamic] +version = { attr = "linode_api4.version.__version__" } + +[tool.setuptools.packages.find] +exclude = ['contrib', 'docs', 'test', 'test.*'] + [tool.isort] profile = "black" line_length = 80 diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index d80051aa8..000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,13 +0,0 @@ -black>=23.1.0 -isort>=5.12.0 -autoflake>=2.0.1 -pylint -mock>=5.0.0 -tox>=4.4.0 -Sphinx>=6.0.0 -sphinx-autobuild>=2021.3.14 -sphinxcontrib-fulltoc>=1.2.0 -pytest>=7.3.1 -httpretty>=1.1.4 -build>=0.10.0 -twine>=4.0.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 183da90a9..000000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -httplib2 -enum34 -requests -polling>=0.3.2 \ No newline at end of file diff --git a/setup.py b/setup.py index 8c73e3384..606849326 100755 --- a/setup.py +++ b/setup.py @@ -1,132 +1,3 @@ -#!/usr/bin/env python3 -""" -A setuptools based setup module +from setuptools import setup -Based on a template here: -https://github.com/pypa/sampleproject/blob/master/setup.py -""" -import os -# Always prefer setuptools over distutils -import sys -# To use a consistent encoding -from codecs import open -from os import path -from unittest import TestLoader - -from setuptools import find_packages, setup - -here = path.abspath(path.dirname(__file__)) - - -def get_test_suite(): - test_loader = TestLoader() - return test_loader.discover('test', pattern='*_test.py') - - -def get_baked_version(): - """ - Attempts to read the version from the baked_version file - """ - with open("./baked_version", "r", encoding="utf-8") as f: - result = f.read() - - return result - - -def bake_version(v): - """ - Writes the given version to the baked_version file - """ - with open("./baked_version", "w", encoding="utf-8") as f: - f.write(v) - - -# Get the long description from the README file -with open(path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = f.read() - -# If there's already a baked version, use it rather than attempting -# to resolve the version from env. -# This is useful for installing from an SDist where the version -# cannot be dynamically resolved. -# -# NOTE: baked_version is deleted when running `make build` and `make install`, -# so it should always be recreated during the build process. -if path.isfile("baked_version"): - version = get_baked_version() -else: - # Otherwise, retrieve and bake the version as normal - version = os.getenv("LINODE_SDK_VERSION", "0.0.0") - bake_version(version) - -if version.startswith("v"): - version = version[1:] - -setup( - name='linode_api4', - - # Versions should comply with PEP440. For a discussion on single-sourcing - # the version across setup.py and the project code, see - # https://packaging.python.org/en/latest/single_source_version.html - version=version, - - description='The official python SDK for Linode API v4', - long_description=long_description, - long_description_content_type="text/x-rst", - - # The project's main homepage. - url='https://github.com/linode/linode_api4-python', - - # Author details - author='Linode', - author_email='developers@linode.com', - - # Choose your license - license='BSD 3-Clause License', - - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - 'Development Status :: 5 - Production/Stable', - - # Indicate who your project is intended for - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries', - - # Pick your license as you wish (should match "license" above) - 'License :: OSI Approved :: BSD License', - - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - ], - - # What does your project relate to? - keywords='linode cloud hosting infrastructure', - - # You can just specify the packages manually here if your project is - # simple. Or you can use find_packages(). - packages=find_packages(exclude=['contrib', 'docs', 'test', 'test.*']), - - # What do we need for this to run - python_requires=">=3.8", - - install_requires=[ - "requests", - "polling" - ], - - extras_require={ - "test": ["tox"], - }, - test_suite='setup.get_test_suite' -) +setup() diff --git a/tox.ini b/tox.ini index 707f91353..209db7170 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,py38,py39,py310,py311 +envlist = py38,py39,py310,py311,py312 skip_missing_interpreters = true [testenv] From 10ad3166d09991e927bde11f763db76990f46767 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 26 Feb 2024 12:21:14 -0500 Subject: [PATCH 169/379] new: Allow defining interfaces on Instance creation (#372) * enable interfaces * simplify code --- linode_api4/groups/linode.py | 9 +++++ .../linode_client/test_linode_client.py | 34 ++++++++++++++++++- test/unit/objects/linode_test.py | 29 ++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 0c114e2ef..f76cb99c0 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -265,6 +265,9 @@ def instance_create( :type metadata: dict :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall + :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. + At least one and up to three Interface objects can exist in this array. + :type interfaces: list[ConfigInterface] or list[dict[str, Any]] :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -303,6 +306,12 @@ def instance_create( fw = kwargs.pop("firewall") kwargs["firewall_id"] = fw.id if isinstance(fw, Firewall) else fw + if "interfaces" in kwargs: + kwargs["interfaces"] = [ + i._serialize() if isinstance(i, ConfigInterface) else i + for i in kwargs["interfaces"] + ] + params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index e68f54eb2..d7aee9d8c 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -5,7 +5,7 @@ import pytest from linode_api4 import ApiError, LinodeClient -from linode_api4.objects import ObjectStorageKeys +from linode_api4.objects import ConfigInterface, ObjectStorageKeys @pytest.fixture(scope="session", autouse=True) @@ -231,6 +231,38 @@ def test_create_linode_instance_with_image(setup_client_and_linode): assert re.search("linode/debian10", str(linode.image)) +def test_create_linode_with_interfaces(test_linode_client): + client = test_linode_client + available_regions = client.regions() + chosen_region = available_regions[4] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + chosen_region, + label=label, + image="linode/debian10", + interfaces=[ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ], + ) + + assert len(linode_instance.configs[0].interfaces) == 2 + assert linode_instance.configs[0].interfaces[0].purpose == "public" + assert linode_instance.configs[0].interfaces[1].purpose == "vlan" + assert linode_instance.configs[0].interfaces[1].label == "cool-vlan" + assert ( + linode_instance.configs[0].interfaces[1].ipam_address == "10.0.0.4/32" + ) + + res = linode_instance.delete() + + assert res + + # LongviewGroupTests def test_get_longview_clients(test_linode_client, test_longview_client): client = test_linode_client diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 4121380e4..e9362eabb 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -421,6 +421,35 @@ def test_instance_create_with_user_data(self): }, ) + def test_instance_create_with_interfaces(self): + """ + Tests that user can pass a list of interfaces on Linode create. + """ + interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ] + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "us-southeast", + "g6-nanode-1", + interfaces=interfaces, + ) + + self.assertEqual( + m.call_data["interfaces"], + [ + {"purpose": "public"}, + { + "purpose": "vlan", + "label": "cool-vlan", + "ipam_address": "10.0.0.4/32", + }, + ], + ) + def test_build_instance_metadata(self): """ Tests that the metadata field is built correctly. From fc27a62c96c421f02f646dde2978d75e13b31033 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 28 Feb 2024 11:49:04 -0500 Subject: [PATCH 170/379] fix: Make NodeBalancer.tags mutable (#371) * Make NodeBalancer.tags mutable * Use printf * make format --- Makefile | 2 +- linode_api4/objects/nodebalancer.py | 2 +- test/fixtures/nodebalancers_123456.json | 14 ++++++++ test/unit/objects/nodebalancers_test.py | 47 ++++++++++++++++++------- 4 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 test/fixtures/nodebalancers_123456.json diff --git a/Makefile b/Makefile index 1276e7639..0589eec02 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ build: clean create-version .PHONY: create-version create-version: - @echo "${VERSION_MODULE_DOCSTRING}__version__ = \"${LINODE_SDK_VERSION}\"" > $(VERSION_FILE) + @printf "${VERSION_MODULE_DOCSTRING}__version__ = \"${LINODE_SDK_VERSION}\"\n" > $(VERSION_FILE) .PHONY: release release: build diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index ca4228d16..99c88f3c5 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -217,7 +217,7 @@ class NodeBalancer(Base): "region": Property(slug_relationship=Region), "configs": Property(derived_class=NodeBalancerConfig), "transfer": Property(), - "tags": Property(), + "tags": Property(mutable=True), } # create derived objects diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json new file mode 100644 index 000000000..e965d4379 --- /dev/null +++ b/test/fixtures/nodebalancers_123456.json @@ -0,0 +1,14 @@ +{ + "created": "2018-01-01T00:01:01", + "ipv6": "c001:d00d:b01::1:abcd:1234", + "region": "us-east-1a", + "ipv4": "12.34.56.789", + "hostname": "nb-12-34-56-789.newark.nodebalancer.linode.com", + "id": 123456, + "updated": "2018-01-01T00:01:01", + "label": "balancer123456", + "client_conn_throttle": 0, + "tags": [ + "something" + ] +} \ No newline at end of file diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index 24f702f7f..05f0ad7de 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -132,6 +132,41 @@ def test_delete_node(self): m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321" ) + +class NodeBalancerTest(ClientBaseCase): + def test_update(self): + """ + Test that you can update a NodeBalancer. + """ + nb = NodeBalancer(self.client, 123456) + nb.label = "updated-label" + nb.client_conn_throttle = 7 + nb.tags = ["foo", "bar"] + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + self.assertEqual( + m.call_data, + { + "label": "updated-label", + "client_conn_throttle": 7, + "tags": ["foo", "bar"], + }, + ) + + def test_firewalls(self): + """ + Test that you can get the firewalls for the requested NodeBalancer. + """ + nb = NodeBalancer(self.client, 12345) + firewalls_url = "/nodebalancers/12345/firewalls" + + with self.mock_get(firewalls_url) as m: + result = nb.firewalls() + self.assertEqual(m.call_url, firewalls_url) + self.assertEqual(len(result), 1) + def test_config_rebuild(self): """ Test that you can rebuild the cofig of a node balancer. @@ -193,15 +228,3 @@ def test_statistics(self): "linode.com - balancer12345 (12345) - day (5 min avg)", ) self.assertEqual(m.call_url, statistics_url) - - def test_firewalls(self): - """ - Test that you can get the firewalls for the requested NodeBalancer. - """ - nb = NodeBalancer(self.client, 12345) - firewalls_url = "/nodebalancers/12345/firewalls" - - with self.mock_get(firewalls_url) as m: - result = nb.firewalls() - self.assertEqual(m.call_url, firewalls_url) - self.assertEqual(len(result), 1) From 15c235b0d550482240e248aec45a232496b7e87f Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Tue, 5 Mar 2024 13:47:59 -0500 Subject: [PATCH 171/379] v5.13.0 (#374) --- .github/workflows/e2e-test-pr.yml | 14 +- .github/workflows/lint.yml | 6 +- .github/workflows/main.yml | 8 +- .github/workflows/nightly-smoke-tests.yml | 8 +- .github/workflows/publish-pypi.yaml | 12 +- Makefile | 43 +++--- linode_api4/groups/linode.py | 9 ++ linode_api4/objects/nodebalancer.py | 2 +- linode_api4/version.py | 5 + pyproject.toml | 66 ++++++++- requirements-dev.txt | 13 -- requirements.txt | 4 - setup.py | 133 +----------------- test/fixtures/nodebalancers_123456.json | 14 ++ .../linode_client/test_linode_client.py | 34 ++++- test/unit/objects/linode_test.py | 29 ++++ test/unit/objects/nodebalancers_test.py | 47 +++++-- tox.ini | 2 +- 18 files changed, 237 insertions(+), 212 deletions(-) create mode 100644 linode_api4/version.py delete mode 100644 requirements-dev.txt delete mode 100644 requirements.txt create mode 100644 test/fixtures/nodebalancers_123456.json diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index e0f9c0888..ac40302c6 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -32,7 +32,7 @@ jobs: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ inputs.sha }} fetch-depth: 0 @@ -63,22 +63,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Update system packages - run: sudo apt-get update -y - - - name: Install system deps - run: sudo apt-get install -y build-essential - - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install -U setuptools wheel boto3 certifi - name: Install Python SDK - run: make install + run: make dev-install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6f55a491e..9f9391533 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,15 +10,15 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: setup python 3 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: install dependencies - run: make requirements + run: make dev-install - name: run linter run: make lint \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0813c2581..ee290360c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8','3.9','3.10','3.11'] + python-version: ['3.8','3.9','3.10','3.11', '3.12'] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Run tests run: | - pip install .[test] + pip install ".[test]" tox diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index f2934e604..b1a0fcbfd 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -11,20 +11,20 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: dev - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install Python deps - run: pip install -r requirements.txt -r requirements-dev.txt wheel boto3 + run: pip install -U setuptools wheel boto3 certifi - name: Install Python SDK - run: make install + run: make dev-install env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index c8e47f755..bca202209 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -8,16 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 - - - name: Update system packages - run: sudo apt-get update -y - - - name: Install make - run: sudo apt-get install -y build-essential + uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' @@ -30,6 +24,6 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@a56da0b891b3dc519c7ee3284aff1fad93cc8598 # pin@release/v1.8.6 + uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf # pin@release/v1.8.11 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/Makefile b/Makefile index 7482f9073..0589eec02 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,10 @@ INTEGRATION_TEST_PATH := TEST_CASE_COMMAND := MODEL_COMMAND := +LINODE_SDK_VERSION ?= "0.0.0.dev" +VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n +VERSION_FILE := ./linode_api4/version.py + ifdef TEST_CASE TEST_CASE_COMMAND = -k $(TEST_CASE) endif @@ -12,45 +16,48 @@ ifdef TEST_MODEL MODEL_COMMAND = models/$(TEST_MODEL) endif -@PHONEY: clean +.PHONY: clean clean: mkdir -p dist rm -r dist rm -f baked_version -@PHONEY: build -build: clean +.PHONY: build +build: clean create-version $(PYTHON) -m build --wheel --sdist +.PHONY: create-version +create-version: + @printf "${VERSION_MODULE_DOCSTRING}__version__ = \"${LINODE_SDK_VERSION}\"\n" > $(VERSION_FILE) -@PHONEY: release +.PHONY: release release: build $(PYTHON) -m twine upload dist/* -@PHONEY: install -install: clean requirements - $(PYTHON) -m pip install . +.PHONY: dev-install +dev-install: clean + $(PYTHON) -m pip install -e ".[dev]" -@PHONEY: requirements -requirements: - $(PYTHON) -m pip install -r requirements.txt -r requirements-dev.txt +.PHONY: install +install: clean create-version + $(PYTHON) -m pip install . -@PHONEY: black +.PHONY: black black: $(PYTHON) -m black linode_api4 test -@PHONEY: isort +.PHONY: isort isort: $(PYTHON) -m isort linode_api4 test -@PHONEY: autoflake +.PHONY: autoflake autoflake: $(PYTHON) -m autoflake linode_api4 test -@PHONEY: format +.PHONY: format format: black isort autoflake -@PHONEY: lint +.PHONY: lint lint: build $(PYTHON) -m isort --check-only linode_api4 test $(PYTHON) -m autoflake --check linode_api4 test @@ -58,14 +65,14 @@ lint: build $(PYTHON) -m pylint linode_api4 $(PYTHON) -m twine check dist/* -@PHONEY: testint +.PHONY: testint testint: $(PYTHON) -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} -@PHONEY: testunit +.PHONY: testunit testunit: $(PYTHON) -m pytest test/unit -@PHONEY: smoketest +.PHONY: smoketest smoketest: $(PYTHON) -m pytest -m smoke test/integration --disable-warnings \ No newline at end of file diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 0c114e2ef..f76cb99c0 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -265,6 +265,9 @@ def instance_create( :type metadata: dict :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall + :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. + At least one and up to three Interface objects can exist in this array. + :type interfaces: list[ConfigInterface] or list[dict[str, Any]] :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -303,6 +306,12 @@ def instance_create( fw = kwargs.pop("firewall") kwargs["firewall_id"] = fw.id if isinstance(fw, Firewall) else fw + if "interfaces" in kwargs: + kwargs["interfaces"] = [ + i._serialize() if isinstance(i, ConfigInterface) else i + for i in kwargs["interfaces"] + ] + params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index ca4228d16..99c88f3c5 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -217,7 +217,7 @@ class NodeBalancer(Base): "region": Property(slug_relationship=Region), "configs": Property(derived_class=NodeBalancerConfig), "transfer": Property(), - "tags": Property(), + "tags": Property(mutable=True), } # create derived objects diff --git a/linode_api4/version.py b/linode_api4/version.py new file mode 100644 index 000000000..04065ecda --- /dev/null +++ b/linode_api4/version.py @@ -0,0 +1,5 @@ +""" +The version of this linode_api4 package. +""" + +__version__ = "0.0.0.dev" diff --git a/pyproject.toml b/pyproject.toml index dc391f2ca..787cbc46a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,71 @@ [build-system] -requires = ["setuptools"] +requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" + +[project] +name = "linode_api4" +authors = [{ name = "Linode", email = "developers@linode.com" }] +description = "The official Python SDK for Linode API v4" +readme = "README.rst" +requires-python = ">=3.8" +keywords = [ + "akamai", + "Akamai Connected Cloud", + "linode", + "cloud", + "SDK", + "Linode APIv4", +] +license = { text = "BSD-3-Clause" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = ["requests", "polling"] +dynamic = ["version"] + +[project.optional-dependencies] +test = ["tox>=4.4.0"] + +dev = [ + "tox>=4.4.0", + "mock>=5.0.0", + "pytest>=7.3.1", + "httpretty>=1.1.4", + "black>=23.1.0", + "isort>=5.12.0", + "autoflake>=2.0.1", + "pylint", + "twine>=4.0.2", + "build>=0.10.0", + "Sphinx>=6.0.0", + "sphinx-autobuild>=2021.3.14", + "sphinxcontrib-fulltoc>=1.2.0", + "build>=0.10.0", + "twine>=4.0.2", +] + +[project.urls] +Homepage = "https://github.com/linode/linode_api4-python" +Documentation = "https://linode-api4.readthedocs.io/" +Repository = "https://github.com/linode/linode_api4-python.git" + +[tool.setuptools.dynamic] +version = { attr = "linode_api4.version.__version__" } + +[tool.setuptools.packages.find] +exclude = ['contrib', 'docs', 'test', 'test.*'] + [tool.isort] profile = "black" line_length = 80 diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index d80051aa8..000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,13 +0,0 @@ -black>=23.1.0 -isort>=5.12.0 -autoflake>=2.0.1 -pylint -mock>=5.0.0 -tox>=4.4.0 -Sphinx>=6.0.0 -sphinx-autobuild>=2021.3.14 -sphinxcontrib-fulltoc>=1.2.0 -pytest>=7.3.1 -httpretty>=1.1.4 -build>=0.10.0 -twine>=4.0.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 183da90a9..000000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -httplib2 -enum34 -requests -polling>=0.3.2 \ No newline at end of file diff --git a/setup.py b/setup.py index 8c73e3384..606849326 100755 --- a/setup.py +++ b/setup.py @@ -1,132 +1,3 @@ -#!/usr/bin/env python3 -""" -A setuptools based setup module +from setuptools import setup -Based on a template here: -https://github.com/pypa/sampleproject/blob/master/setup.py -""" -import os -# Always prefer setuptools over distutils -import sys -# To use a consistent encoding -from codecs import open -from os import path -from unittest import TestLoader - -from setuptools import find_packages, setup - -here = path.abspath(path.dirname(__file__)) - - -def get_test_suite(): - test_loader = TestLoader() - return test_loader.discover('test', pattern='*_test.py') - - -def get_baked_version(): - """ - Attempts to read the version from the baked_version file - """ - with open("./baked_version", "r", encoding="utf-8") as f: - result = f.read() - - return result - - -def bake_version(v): - """ - Writes the given version to the baked_version file - """ - with open("./baked_version", "w", encoding="utf-8") as f: - f.write(v) - - -# Get the long description from the README file -with open(path.join(here, 'README.rst'), encoding='utf-8') as f: - long_description = f.read() - -# If there's already a baked version, use it rather than attempting -# to resolve the version from env. -# This is useful for installing from an SDist where the version -# cannot be dynamically resolved. -# -# NOTE: baked_version is deleted when running `make build` and `make install`, -# so it should always be recreated during the build process. -if path.isfile("baked_version"): - version = get_baked_version() -else: - # Otherwise, retrieve and bake the version as normal - version = os.getenv("LINODE_SDK_VERSION", "0.0.0") - bake_version(version) - -if version.startswith("v"): - version = version[1:] - -setup( - name='linode_api4', - - # Versions should comply with PEP440. For a discussion on single-sourcing - # the version across setup.py and the project code, see - # https://packaging.python.org/en/latest/single_source_version.html - version=version, - - description='The official python SDK for Linode API v4', - long_description=long_description, - long_description_content_type="text/x-rst", - - # The project's main homepage. - url='https://github.com/linode/linode_api4-python', - - # Author details - author='Linode', - author_email='developers@linode.com', - - # Choose your license - license='BSD 3-Clause License', - - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - 'Development Status :: 5 - Production/Stable', - - # Indicate who your project is intended for - 'Intended Audience :: Developers', - 'Topic :: Software Development :: Libraries', - - # Pick your license as you wish (should match "license" above) - 'License :: OSI Approved :: BSD License', - - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - ], - - # What does your project relate to? - keywords='linode cloud hosting infrastructure', - - # You can just specify the packages manually here if your project is - # simple. Or you can use find_packages(). - packages=find_packages(exclude=['contrib', 'docs', 'test', 'test.*']), - - # What do we need for this to run - python_requires=">=3.8", - - install_requires=[ - "requests", - "polling" - ], - - extras_require={ - "test": ["tox"], - }, - test_suite='setup.get_test_suite' -) +setup() diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json new file mode 100644 index 000000000..e965d4379 --- /dev/null +++ b/test/fixtures/nodebalancers_123456.json @@ -0,0 +1,14 @@ +{ + "created": "2018-01-01T00:01:01", + "ipv6": "c001:d00d:b01::1:abcd:1234", + "region": "us-east-1a", + "ipv4": "12.34.56.789", + "hostname": "nb-12-34-56-789.newark.nodebalancer.linode.com", + "id": 123456, + "updated": "2018-01-01T00:01:01", + "label": "balancer123456", + "client_conn_throttle": 0, + "tags": [ + "something" + ] +} \ No newline at end of file diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index e68f54eb2..d7aee9d8c 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -5,7 +5,7 @@ import pytest from linode_api4 import ApiError, LinodeClient -from linode_api4.objects import ObjectStorageKeys +from linode_api4.objects import ConfigInterface, ObjectStorageKeys @pytest.fixture(scope="session", autouse=True) @@ -231,6 +231,38 @@ def test_create_linode_instance_with_image(setup_client_and_linode): assert re.search("linode/debian10", str(linode.image)) +def test_create_linode_with_interfaces(test_linode_client): + client = test_linode_client + available_regions = client.regions() + chosen_region = available_regions[4] + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + chosen_region, + label=label, + image="linode/debian10", + interfaces=[ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ], + ) + + assert len(linode_instance.configs[0].interfaces) == 2 + assert linode_instance.configs[0].interfaces[0].purpose == "public" + assert linode_instance.configs[0].interfaces[1].purpose == "vlan" + assert linode_instance.configs[0].interfaces[1].label == "cool-vlan" + assert ( + linode_instance.configs[0].interfaces[1].ipam_address == "10.0.0.4/32" + ) + + res = linode_instance.delete() + + assert res + + # LongviewGroupTests def test_get_longview_clients(test_linode_client, test_longview_client): client = test_linode_client diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 4121380e4..e9362eabb 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -421,6 +421,35 @@ def test_instance_create_with_user_data(self): }, ) + def test_instance_create_with_interfaces(self): + """ + Tests that user can pass a list of interfaces on Linode create. + """ + interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ] + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "us-southeast", + "g6-nanode-1", + interfaces=interfaces, + ) + + self.assertEqual( + m.call_data["interfaces"], + [ + {"purpose": "public"}, + { + "purpose": "vlan", + "label": "cool-vlan", + "ipam_address": "10.0.0.4/32", + }, + ], + ) + def test_build_instance_metadata(self): """ Tests that the metadata field is built correctly. diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index 24f702f7f..05f0ad7de 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -132,6 +132,41 @@ def test_delete_node(self): m.call_url, "/nodebalancers/123456/configs/65432/nodes/54321" ) + +class NodeBalancerTest(ClientBaseCase): + def test_update(self): + """ + Test that you can update a NodeBalancer. + """ + nb = NodeBalancer(self.client, 123456) + nb.label = "updated-label" + nb.client_conn_throttle = 7 + nb.tags = ["foo", "bar"] + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + self.assertEqual( + m.call_data, + { + "label": "updated-label", + "client_conn_throttle": 7, + "tags": ["foo", "bar"], + }, + ) + + def test_firewalls(self): + """ + Test that you can get the firewalls for the requested NodeBalancer. + """ + nb = NodeBalancer(self.client, 12345) + firewalls_url = "/nodebalancers/12345/firewalls" + + with self.mock_get(firewalls_url) as m: + result = nb.firewalls() + self.assertEqual(m.call_url, firewalls_url) + self.assertEqual(len(result), 1) + def test_config_rebuild(self): """ Test that you can rebuild the cofig of a node balancer. @@ -193,15 +228,3 @@ def test_statistics(self): "linode.com - balancer12345 (12345) - day (5 min avg)", ) self.assertEqual(m.call_url, statistics_url) - - def test_firewalls(self): - """ - Test that you can get the firewalls for the requested NodeBalancer. - """ - nb = NodeBalancer(self.client, 12345) - firewalls_url = "/nodebalancers/12345/firewalls" - - with self.mock_get(firewalls_url) as m: - result = nb.firewalls() - self.assertEqual(m.call_url, firewalls_url) - self.assertEqual(len(result), 1) diff --git a/tox.ini b/tox.ini index 707f91353..209db7170 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py37,py38,py39,py310,py311 +envlist = py38,py39,py310,py311,py312 skip_missing_interpreters = true [testenv] From ce9eb0d41875ad84ee5e537e0d5919a4ac01707a Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 6 Mar 2024 15:16:58 -0500 Subject: [PATCH 172/379] Check `None` for `interfaces` parameter in key word arguments of `instance_create` function (#375) --- linode_api4/groups/linode.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index f76cb99c0..982eede81 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,5 +1,6 @@ import base64 import os +from collections.abc import Iterable from linode_api4 import Profile from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys @@ -307,10 +308,12 @@ def instance_create( kwargs["firewall_id"] = fw.id if isinstance(fw, Firewall) else fw if "interfaces" in kwargs: - kwargs["interfaces"] = [ - i._serialize() if isinstance(i, ConfigInterface) else i - for i in kwargs["interfaces"] - ] + interfaces = kwargs.get("interfaces") + if interfaces is not None and isinstance(interfaces, Iterable): + kwargs["interfaces"] = [ + i._serialize() if isinstance(i, ConfigInterface) else i + for i in interfaces + ] params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, From 886c194a3f771eeababa4228369a1d8682e4fd82 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 8 Mar 2024 14:50:02 -0500 Subject: [PATCH 173/379] new: Add support for Parent/Child account switching (#373) * Changes for parent/child account project * Improved test --- linode_api4/groups/account.py | 13 +++++++ linode_api4/objects/account.py | 35 ++++++++++++++++++ test/fixtures/account_child-accounts.json | 36 +++++++++++++++++++ .../account_child-accounts_123456.json | 29 +++++++++++++++ .../account_child-accounts_123456_token.json | 8 +++++ test/integration/models/test_account.py | 10 ++++++ test/unit/objects/account_test.py | 19 ++++++++++ 7 files changed, 150 insertions(+) create mode 100644 test/fixtures/account_child-accounts.json create mode 100644 test/fixtures/account_child-accounts_123456.json create mode 100644 test/fixtures/account_child-accounts_123456_token.json diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 55eab9436..0f20cf311 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -8,6 +8,7 @@ AccountBetaProgram, AccountSettings, BetaProgram, + ChildAccount, Event, Invoice, Login, @@ -18,6 +19,7 @@ ServiceTransfer, User, ) +from linode_api4.objects.profile import PersonalAccessToken class AccountGroup(Group): @@ -496,3 +498,14 @@ def availabilities(self, *filters): :rtype: PaginatedList of AccountAvailability """ return self.client._get_and_filter(AccountAvailability, *filters) + + def child_accounts(self, *filters): + """ + Returns a list of all child accounts under the this parent account. + + API doc: TBD + + :returns: a list of all child accounts. + :rtype: PaginatedList of ChildAccount + """ + return self.client._get_and_filter(ChildAccount, *filters) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index f50c4da32..69a30db79 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from datetime import datetime import requests @@ -16,6 +18,7 @@ ) from linode_api4.objects.longview import LongviewClient, LongviewSubscription from linode_api4.objects.nodebalancer import NodeBalancer +from linode_api4.objects.profile import PersonalAccessToken from linode_api4.objects.support import SupportTicket @@ -53,6 +56,37 @@ class Account(Base): } +class ChildAccount(Account): + """ + A child account under a parent account. + + API Documentation: TBD + """ + + api_endpoint = "/account/child-accounts/{euuid}" + id_attribute = "euuid" + + def create_token(self, **kwargs): + """ + Create a ephemeral token for accessing the child account. + + API Documentation: TBD + """ + resp = self._client.post( + "{}/token".format(self.api_endpoint), + model=self, + data=kwargs, + ) + + if "errors" in resp: + raise UnexpectedResponseError( + "Unexpected response when creating a token for the child account!", + json=resp, + ) + + return PersonalAccessToken(self._client, resp["id"], resp) + + class ServiceTransfer(Base): """ A transfer request for transferring a service between Linode accounts. @@ -476,6 +510,7 @@ class User(Base): properties = { "email": Property(), "username": Property(identifier=True, mutable=True), + "user_type": Property(), "restricted": Property(mutable=True), "ssh_keys": Property(), "tfa_enabled": Property(), diff --git a/test/fixtures/account_child-accounts.json b/test/fixtures/account_child-accounts.json new file mode 100644 index 000000000..e7e9aca43 --- /dev/null +++ b/test/fixtures/account_child-accounts.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "active_since": "2018-01-01T00:01:01", + "address_1": "123 Main Street", + "address_2": "Suite A", + "balance": 200, + "balance_uninvoiced": 145, + "billing_source": "external", + "capabilities": [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage" + ], + "city": "Philadelphia", + "company": "Linode LLC", + "country": "US", + "credit_card": { + "expiry": "11/2022", + "last_four": 1111 + }, + "email": "john.smith@linode.com", + "euuid": "E1AF5EEC-526F-487D-B317EBEB34C87D71", + "first_name": "John", + "last_name": "Smith", + "phone": "215-555-1212", + "state": "PA", + "tax_id": "ATU99999999", + "zip": "19102-1234" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/account_child-accounts_123456.json b/test/fixtures/account_child-accounts_123456.json new file mode 100644 index 000000000..8ce264693 --- /dev/null +++ b/test/fixtures/account_child-accounts_123456.json @@ -0,0 +1,29 @@ +{ + "active_since": "2018-01-01T00:01:01", + "address_1": "123 Main Street", + "address_2": "Suite A", + "balance": 200, + "balance_uninvoiced": 145, + "billing_source": "external", + "capabilities": [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage" + ], + "city": "Philadelphia", + "company": "Linode LLC", + "country": "US", + "credit_card": { + "expiry": "11/2022", + "last_four": 1111 + }, + "email": "john.smith@linode.com", + "euuid": "E1AF5EEC-526F-487D-B317EBEB34C87D71", + "first_name": "John", + "last_name": "Smith", + "phone": "215-555-1212", + "state": "PA", + "tax_id": "ATU99999999", + "zip": "19102-1234" +} \ No newline at end of file diff --git a/test/fixtures/account_child-accounts_123456_token.json b/test/fixtures/account_child-accounts_123456_token.json new file mode 100644 index 000000000..44afea72b --- /dev/null +++ b/test/fixtures/account_child-accounts_123456_token.json @@ -0,0 +1,8 @@ +{ + "created": "2024-01-01T00:01:01", + "expiry": "2024-01-01T13:46:32", + "id": 123, + "label": "cool_customer_proxy", + "scopes": "*", + "token": "abcdefghijklmnop" +} \ No newline at end of file diff --git a/test/integration/models/test_account.py b/test/integration/models/test_account.py index 3d5fa2d97..651268b82 100644 --- a/test/integration/models/test_account.py +++ b/test/integration/models/test_account.py @@ -11,6 +11,7 @@ OAuthClient, User, ) +from linode_api4.objects.account import ChildAccount @pytest.mark.smoke @@ -101,3 +102,12 @@ def test_get_user(test_linode_client): assert username == user.username assert "email" in user._raw_json assert "email" in user._raw_json + + +def test_list_child_accounts(test_linode_client): + client = test_linode_client + child_accounts = client.account.child_accounts() + if len(child_accounts) > 0: + child_account = ChildAccount(client, child_accounts[0].euuid) + child_account._api_get() + child_account.create_token() diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 0f53240f4..1ec344a7f 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -24,6 +24,7 @@ Volume, get_obj_grants, ) +from linode_api4.objects.account import ChildAccount class InvoiceTest(ClientBaseCase): @@ -278,3 +279,21 @@ def test_account_availability_api_get(self): self.assertEqual(availability.unavailable, []) self.assertEqual(m.call_url, account_availability_url) + + +class ChildAccountTest(ClientBaseCase): + """ + Test methods of the ChildAccount + """ + + def test_child_account_api_list(self): + result = self.client.account.child_accounts() + self.assertEqual(len(result), 1) + self.assertEqual(result[0].euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71") + + def test_child_account_create_token(self): + child_account = self.client.load(ChildAccount, 123456) + with self.mock_post("/account/child-accounts/123456/token") as m: + token = child_account.create_token() + self.assertEqual(token.token, "abcdefghijklmnop") + self.assertEqual(m.call_data, {}) From bf7f1732ca5f442900db7cf25bfe0cc7db508974 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 12 Mar 2024 13:46:58 -0700 Subject: [PATCH 174/379] new: Adding cross repo testing workflow for Release (#378) * move test upload logic to git submodule, and use it in e2e workflow * update script folder name * Test release-cross-repo-test.yml * trial 2 * 3 * switch order of python build * 5 * fix syntax * update python version * 6 * 7 * 8 * Final clean up and fix make dep installs * remove test_scripts * change job name * Pr comments * Pr comments --- .github/workflows/release-cross-repo-test.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/release-cross-repo-test.yml diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml new file mode 100644 index 000000000..0f484d9af --- /dev/null +++ b/.github/workflows/release-cross-repo-test.yml @@ -0,0 +1,61 @@ +name: Release Ansible cross repository test + +on: + pull_request: + branches: + - main + types: [opened] # Workflow will only be executed when PR is opened to main branch + workflow_dispatch: # Manual trigger + + +jobs: + ansible_integration_test: + runs-on: ubuntu-latest + steps: + - name: Checkout linode_api4 repository + uses: actions/checkout@v4 + + - name: update packages + run: sudo apt-get update -y + + - name: install make + run: sudo apt-get install -y build-essential + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install linode_api4 + run: make install + + - name: checkout repo + uses: actions/checkout@v3 + with: + repository: linode/ansible_linode + path: .ansible/collections/ansible_collections/linode/cloud + + - name: install dependencies + run: | + cd .ansible/collections/ansible_collections/linode/cloud + pip install -r requirements.txt -r requirements-dev.txt --upgrade-strategy only-if-needed + + - name: install ansible dependencies + run: ansible-galaxy collection install amazon.aws:==6.0.1 + + - name: install collection + run: | + cd .ansible/collections/ansible_collections/linode/cloud + make install + + - name: replace existing keys + run: | + cd .ansible/collections/ansible_collections/linode/cloud + rm -rf ~/.ansible/test && mkdir -p ~/.ansible/test && ssh-keygen -m PEM -q -t rsa -N '' -f ~/.ansible/test/id_rsa + + - name: run tests + run: | + cd .ansible/collections/ansible_collections/linode/cloud + make testall + env: + LINODE_API_TOKEN: ${{ secrets.LINODE_TOKEN }} From a0393db7354d682ba87a64a7b4e17c862c4035c0 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 13 Mar 2024 15:32:58 -0400 Subject: [PATCH 175/379] doc: Add .readthedocs.yaml config file (#381) * Add .readthedocs.yaml --- .readthedocs.yaml | 13 +++++++++++++ docs/requirements.txt | 1 + 2 files changed, 14 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..c088d2c5b --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.12" +sphinx: + configuration: docs/conf.py +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 0eee2117c..2b939141a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1 +1,2 @@ sphinxcontrib-fulltoc +. \ No newline at end of file From fd878dde71c771440ac3911911f77959bb335359 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:32:01 -0400 Subject: [PATCH 176/379] doc: Add missing models and groups to documentation (#383) * Include missing models and groups * Update copyright --- docs/conf.py | 2 +- docs/linode_api4/linode_client.rst | 20 +++++++++++++++++++- docs/linode_api4/objects/models.rst | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd1654ef0..cd15307ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ # -- Project information ----------------------------------------------------- project = 'linode_api4' -copyright = '2023, Linode' +copyright = '2024, Akamai Technologies Inc.' author = 'Linode' # The short X.Y version diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index b87a6a18f..58c7025b8 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -61,6 +61,15 @@ Includes methods for managing your account. :members: :special-members: +BetaGroup +^^^^^^^^^ + +Includes methods for enrolling in beta programs. + +.. autoclass:: linode_api4.linode_client.BetaGroup + :members: + :special-members: + DatabaseGroup ^^^^^^^^^^^^^ @@ -98,7 +107,7 @@ accessing and working with associated features. :members: :special-members: -LKE Group +LKEGroup ^^^^^^^^^ Includes methods for interacting with Linode Kubernetes Engine. @@ -199,3 +208,12 @@ Includes methods for managing Linode Volumes. .. autoclass:: linode_api4.linode_client.VolumeGroup :members: :special-members: + +VPCGroup +^^^^^^^^ + +Includes methods for managing Linode VPCs. + +.. autoclass:: linode_api4.linode_client.VPCGroup + :members: + :special-members: diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 7ea664940..6805ad889 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -14,6 +14,15 @@ Account Models :undoc-members: :inherited-members: +Beta Models +----------- + +.. automodule:: linode_api4.objects.beta + :members: + :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name + :undoc-members: + :inherited-members: + Database Models ------------- @@ -139,3 +148,12 @@ Volume Models :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name :undoc-members: :inherited-members: + +VPC Models +---------- + +.. automodule:: linode_api4.objects.vpc + :members: + :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name + :undoc-members: + :inherited-members: From 49dd2c6deab5d64fadf7e69f91d1324282ffee27 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 18 Mar 2024 12:58:16 -0400 Subject: [PATCH 177/379] Improve `.readthedocs.yaml` config file (#382) --- .readthedocs.yaml | 11 +++++++---- docs/requirements.txt | 2 -- pyproject.toml | 8 +++++++- 3 files changed, 14 insertions(+), 7 deletions(-) delete mode 100644 docs/requirements.txt diff --git a/.readthedocs.yaml b/.readthedocs.yaml index c088d2c5b..3fad08aad 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,11 +3,14 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-lts-latest tools: - python: "3.12" + python: latest sphinx: configuration: docs/conf.py python: - install: - - requirements: docs/requirements.txt \ No newline at end of file + install: + - method: pip + path: . + extra_requirements: + - doc \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 2b939141a..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -sphinxcontrib-fulltoc -. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 787cbc46a..cec2adf11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,11 +50,17 @@ dev = [ "build>=0.10.0", "Sphinx>=6.0.0", "sphinx-autobuild>=2021.3.14", - "sphinxcontrib-fulltoc>=1.2.0", + "sphinxcontrib-fulltoc>=1.2.0", "build>=0.10.0", "twine>=4.0.2", ] +doc = [ + "Sphinx>=6.0.0", + "sphinx-autobuild>=2021.3.14", + "sphinxcontrib-fulltoc>=1.2.0", +] + [project.urls] Homepage = "https://github.com/linode/linode_api4-python" Documentation = "https://linode-api4.readthedocs.io/" From 95d0b20ea2f50d6277c658288f1e35966aae40b2 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 18 Mar 2024 13:04:48 -0400 Subject: [PATCH 178/379] new: Add `vpc` field to `Instance(...).ips` property method result (#379) * Support ipv4.vpc field in Instance.ips property method * Update integration test * Add null check --- linode_api4/objects/linode.py | 7 +- linode_api4/objects/networking.py | 27 ++++ test/fixtures/linode_instances_123_ips.json | 171 +++++++++++--------- test/integration/models/test_linode.py | 14 ++ test/unit/objects/linode_test.py | 28 ++-- 5 files changed, 160 insertions(+), 87 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 9fcdae113..9477dd105 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -21,7 +21,7 @@ ) from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute -from linode_api4.objects.networking import IPAddress, IPv6Range +from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList @@ -693,6 +693,10 @@ def ips(self): i = IPAddress(self._client, c["address"], c) reserved.append(i) + vpc = [ + VPCIPAddress.from_json(v) for v in result["ipv4"].get("vpc", []) + ] + slaac = IPAddress( self._client, result["ipv6"]["slaac"]["address"], @@ -716,6 +720,7 @@ def ips(self): "private": v4pri, "shared": shared_ips, "reserved": reserved, + "vpc": vpc, }, "ipv6": { "slaac": slaac, diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 1b0e46994..17d0ec4c6 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, JSONObject, Property, Region @@ -106,6 +107,32 @@ def to(self, linode): return {"address": self.address, "linode_id": linode.id} +@dataclass +class VPCIPAddress(JSONObject): + """ + VPCIPAddress represents the IP address of a VPC. + + NOTE: This is not implemented as a typical API object (Base) because VPC IPs + cannot be refreshed through the /networking/ips/{address} endpoint. + """ + + address: str = "" + gateway: str = "" + region: str = "" + subnet_mask: str = "" + vpc_id: int = 0 + subnet_id: int = 0 + linode_id: int = 0 + config_id: int = 0 + interface_id: int = 0 + prefix: int = 0 + + active: bool = False + + address_range: Optional[str] = None + nat_1_1: Optional[str] = None + + class VLAN(Base): """ .. note:: At this time, the Linode API only supports listing VLANs. diff --git a/test/fixtures/linode_instances_123_ips.json b/test/fixtures/linode_instances_123_ips.json index 147020248..22d61f7b0 100644 --- a/test/fixtures/linode_instances_123_ips.json +++ b/test/fixtures/linode_instances_123_ips.json @@ -1,89 +1,106 @@ { - "ipv4": { - "private": [ - { - "address": "192.168.133.234", - "gateway": null, - "linode_id": 123, - "prefix": 17, - "public": false, - "rdns": null, - "region": "us-east", - "subnet_mask": "255.255.128.0", - "type": "ipv4" - } - ], - "public": [ - { - "address": "97.107.143.141", - "gateway": "97.107.143.1", - "linode_id": 123, - "prefix": 24, - "public": true, - "rdns": "test.example.org", - "region": "us-east", - "subnet_mask": "255.255.255.0", - "type": "ipv4" - } - ], - "reserved": [ - { - "address": "97.107.143.141", - "gateway": "97.107.143.1", - "linode_id": 123, - "prefix": 24, - "public": true, - "rdns": "test.example.org", - "region": "us-east", - "subnet_mask": "255.255.255.0", - "type": "ipv4" - } - ], - "shared": [ - { - "address": "97.107.143.141", - "gateway": "97.107.143.1", - "linode_id": 123, - "prefix": 24, - "public": true, - "rdns": "test.example.org", - "region": "us-east", - "subnet_mask": "255.255.255.0", - "type": "ipv4" - } - ] - }, - "ipv6": { - "global": [ - { - "prefix": 124, - "range": "2600:3c01::2:5000:0", - "region": "us-east", - "route_target": "2600:3c01::2:5000:f" - } - ], - "link_local": { - "address": "fe80::f03c:91ff:fe24:3a2f", - "gateway": "fe80::1", + "ipv4": { + "private": [ + { + "address": "192.168.133.234", + "gateway": null, "linode_id": 123, - "prefix": 64, + "prefix": 17, "public": false, "rdns": null, "region": "us-east", - "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", - "type": "ipv6" - }, - "slaac": { - "address": "2600:3c03::f03c:91ff:fe24:3a2f", - "gateway": "fe80::1", + "subnet_mask": "255.255.128.0", + "type": "ipv4" + } + ], + "public": [ + { + "address": "97.107.143.141", + "gateway": "97.107.143.1", "linode_id": 123, - "prefix": 64, + "prefix": 24, "public": true, - "rdns": null, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4" + } + ], + "reserved": [ + { + "address": "97.107.143.141", + "gateway": "97.107.143.1", + "linode_id": 123, + "prefix": 24, + "public": true, + "rdns": "test.example.org", "region": "us-east", - "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", - "type": "ipv6" + "subnet_mask": "255.255.255.0", + "type": "ipv4" + } + ], + "vpc": [ + { + "address": "10.0.0.2", + "address_range": null, + "vpc_id": 39246, + "subnet_id": 39388, + "region": "us-mia", + "linode_id": 55904908, + "config_id": 59036295, + "interface_id": 1186165, + "active": true, + "nat_1_1": "172.233.179.133", + "gateway": "10.0.0.1", + "prefix": 24, + "subnet_mask": "255.255.255.0" } + ], + "shared": [ + { + "address": "97.107.143.141", + "gateway": "97.107.143.1", + "linode_id": 123, + "prefix": 24, + "public": true, + "rdns": "test.example.org", + "region": "us-east", + "subnet_mask": "255.255.255.0", + "type": "ipv4" + } + ] + }, + "ipv6": { + "global": [ + { + "prefix": 124, + "range": "2600:3c01::2:5000:0", + "region": "us-east", + "route_target": "2600:3c01::2:5000:f" + } + ], + "link_local": { + "address": "fe80::f03c:91ff:fe24:3a2f", + "gateway": "fe80::1", + "linode_id": 123, + "prefix": 64, + "public": false, + "rdns": null, + "region": "us-east", + "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "type": "ipv6" + }, + "slaac": { + "address": "2600:3c03::f03c:91ff:fe24:3a2f", + "gateway": "fe80::1", + "linode_id": 123, + "prefix": 64, + "public": true, + "rdns": null, + "region": "us-east", + "subnet_mask": "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "type": "ipv6" } } +} \ No newline at end of file diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index f46a3fc29..2a69afb65 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -621,6 +621,20 @@ def test_create_vpc( assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] + vpc_ip = linode.ips.ipv4.vpc[0] + vpc_range_ip = linode.ips.ipv4.vpc[1] + + assert vpc_ip.nat_1_1 == linode.ips.ipv4.public[0].address + assert vpc_ip.address_range is None + assert vpc_ip.vpc_id == vpc.id + assert vpc_ip.subnet_id == subnet.id + assert vpc_ip.config_id == config.id + assert vpc_ip.interface_id == interface.id + assert not vpc_ip.active + + assert vpc_range_ip.address_range == "10.0.0.5/32" + assert not vpc_range_ip.active + def test_update_vpc( self, linode_for_network_interface_tests, diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index e9362eabb..c68dcfef6 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -348,15 +348,25 @@ def test_ips(self): ips = linode.ips - self.assertIsNotNone(ips.ipv4) - self.assertIsNotNone(ips.ipv6) - self.assertIsNotNone(ips.ipv4.public) - self.assertIsNotNone(ips.ipv4.private) - self.assertIsNotNone(ips.ipv4.shared) - self.assertIsNotNone(ips.ipv4.reserved) - self.assertIsNotNone(ips.ipv6.slaac) - self.assertIsNotNone(ips.ipv6.link_local) - self.assertIsNotNone(ips.ipv6.ranges) + assert ips.ipv4 is not None + assert ips.ipv6 is not None + assert ips.ipv4.public is not None + assert ips.ipv4.private is not None + assert ips.ipv4.shared is not None + assert ips.ipv4.reserved is not None + assert ips.ipv4.vpc is not None + assert ips.ipv6.slaac is not None + assert ips.ipv6.link_local is not None + assert ips.ipv6.ranges is not None + + vpc_ip = ips.ipv4.vpc[0] + assert vpc_ip.nat_1_1 == "172.233.179.133" + assert vpc_ip.address_range == None + assert vpc_ip.vpc_id == 39246 + assert vpc_ip.subnet_id == 39388 + assert vpc_ip.config_id == 59036295 + assert vpc_ip.interface_id == 1186165 + assert vpc_ip.active def test_initiate_migration(self): """ From fd151d58b876bd924f04275dfdc0cc8e4b2815a3 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:15:52 -0400 Subject: [PATCH 179/379] Support LinodeClient(...).vpcs.ips(...) (#385) --- linode_api4/groups/vpc.py | 25 +++++++++++++++++++++++-- test/fixtures/vpcs_ips.json | 22 ++++++++++++++++++++++ test/integration/models/test_linode.py | 8 ++++++++ test/unit/objects/vpc_test.py | 26 ++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/vpcs_ips.json diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index 635e392dd..f3f4f27b6 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -1,9 +1,8 @@ from typing import Any, Dict, List, Optional, Union -from linode_api4 import VPCSubnet from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import VPC, Base, Region +from linode_api4.objects import VPC, Region, VPCIPAddress from linode_api4.paginated_list import PaginatedList @@ -81,3 +80,25 @@ def create( d = VPC(self.client, result["id"], result) return d + + def ips(self, *filters) -> PaginatedList: + """ + Retrieves all of the VPC IP addresses for the current account matching the given filters. + + This is intended to be called from the :any:`LinodeClient` + class, like this:: + + vpc_ips = client.vpcs.ips() + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of VPCIPAddresses the acting user can access. + :rtype: PaginatedList of VPCIPAddress + """ + return self.client._get_and_filter( + VPCIPAddress, *filters, endpoint="/vpcs/ips" + ) diff --git a/test/fixtures/vpcs_ips.json b/test/fixtures/vpcs_ips.json new file mode 100644 index 000000000..d6f16c2e9 --- /dev/null +++ b/test/fixtures/vpcs_ips.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "address": "10.0.0.2", + "address_range": null, + "vpc_id": 123, + "subnet_id": 456, + "region": "us-mia", + "linode_id": 123, + "config_id": 456, + "interface_id": 789, + "active": true, + "nat_1_1": "172.233.179.133", + "gateway": "10.0.0.1", + "prefix": 24, + "subnet_mask": "255.255.255.0" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 2a69afb65..40d1e735f 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -8,6 +8,7 @@ import pytest +from linode_api4 import VPCIPAddress from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -595,6 +596,7 @@ def test_create_vlan(self, linode_for_network_interface_tests): def test_create_vpc( self, + test_linode_client, linode_for_network_interface_tests, create_vpc_with_subnet_and_linode, ): @@ -635,6 +637,12 @@ def test_create_vpc( assert vpc_range_ip.address_range == "10.0.0.5/32" assert not vpc_range_ip.active + # Attempt to resolve the IP from /vpcs/ips + all_vpc_ips = test_linode_client.vpcs.ips( + VPCIPAddress.filters.linode_id == linode.id + ) + assert all_vpc_ips[0].dict == vpc_ip.dict + def test_update_vpc( self, linode_for_network_interface_tests, diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index c8453ada1..830e9fb9f 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -126,6 +126,32 @@ def test_create_subnet(self): self.validate_vpc_subnet_789(subnet) + def test_list_ips(self): + """ + Validates that all VPC IPs can be listed. + """ + + with self.mock_get("/vpcs/ips") as m: + result = self.client.vpcs.ips() + + assert m.call_url == "/vpcs/ips" + assert len(result) == 1 + + ip = result[0] + assert ip.address == "10.0.0.2" + assert ip.address_range == None + assert ip.vpc_id == 123 + assert ip.subnet_id == 456 + assert ip.region == "us-mia" + assert ip.linode_id == 123 + assert ip.config_id == 456 + assert ip.interface_id == 789 + assert ip.active + assert ip.nat_1_1 == "172.233.179.133" + assert ip.gateway == "10.0.0.1" + assert ip.prefix == 24 + assert ip.subnet_mask == "255.255.255.0" + def validate_vpc_123456(self, vpc: VPC): expected_dt = datetime.datetime.strptime( "2018-01-01T00:01:01", DATE_FORMAT From 58dcd1d231289ff86be0a754b556a6b6e07ecdf4 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 25 Mar 2024 11:16:35 -0400 Subject: [PATCH 180/379] new: Add handling for failed events in EventPoller(...).wait_for_next_event_finished(...) (#384) * Add handling for failed events in EventPoller * oops * oops --- linode_api4/polling.py | 24 +++++++++++++ test/unit/objects/polling_test.py | 58 ++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/linode_api4/polling.py b/linode_api4/polling.py index 537239635..6ba02a5b1 100644 --- a/linode_api4/polling.py +++ b/linode_api4/polling.py @@ -6,6 +6,26 @@ from linode_api4.objects import Event +class EventError(Exception): + """ + Represents a failed Linode event. + """ + + def __init__(self, event_id: int, message: Optional[str]): + # Edge case, sometimes the message is populated with an empty string + if len(message) < 1: + message = None + + self.event_id = event_id + self.message = message + + error_fmt = f"Event {event_id} failed" + if message is not None: + error_fmt += f": {message}" + + super().__init__(error_fmt) + + class TimeoutContext: """ TimeoutContext should be used by polling resources to track their provisioning time. @@ -212,6 +232,10 @@ def wait_for_next_event_finished( def poll_func(): event._api_get() + + if event.status == "failed": + raise EventError(event.id, event.message) + return event.status in ["finished", "notification"] if poll_func(): diff --git a/test/unit/objects/polling_test.py b/test/unit/objects/polling_test.py index b4d3a88cd..7fb7c684f 100644 --- a/test/unit/objects/polling_test.py +++ b/test/unit/objects/polling_test.py @@ -1,9 +1,11 @@ import json +from typing import Optional import httpretty import pytest from linode_api4 import LinodeClient +from linode_api4.polling import EventError class TestPolling: @@ -12,7 +14,11 @@ def client(self): return LinodeClient("testing", base_url="https://localhost") @staticmethod - def body_event_status(status: str, action: str = "linode_shutdown"): + def body_event_status( + status: str, + action: str = "linode_shutdown", + message: Optional[str] = None, + ): return { "action": action, "entity": { @@ -21,6 +27,7 @@ def body_event_status(status: str, action: str = "linode_shutdown"): }, "id": 123, "status": status, + "message": message, } @staticmethod @@ -272,3 +279,52 @@ def test_wait_for_event_finished_creation( assert len(get_requests) == 3 assert result.entity.id == 11111 assert result.status == "finished" + + @httpretty.activate + def test_wait_for_event_finished_failed( + self, + client, + ): + """ + Tests that the EventPoller.wait_for_event_finished method raises errors for failed events. + """ + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events/123", + responses=[ + httpretty.Response( + body=json.dumps(self.body_event_status("started")), + ), + httpretty.Response( + body=json.dumps( + self.body_event_status("failed", message="oh no!") + ), + ), + ], + ) + + httpretty.register_uri( + httpretty.GET, + "https://localhost/account/events", + responses=[ + httpretty.Response( + body=json.dumps(self.body_event_list_empty()), + status=200, + ), + httpretty.Response( + body=json.dumps(self.body_event_list_status("started")), + status=200, + ), + ], + ) + + try: + client.polling.event_poller_create( + "linode", "linode_shutdown", entity_id=11111 + ).wait_for_next_event_finished(interval=0.1) + except EventError as err: + assert err.event_id == 123 + assert err.message == "oh no!" + else: + raise Exception("Expected event error, got none") From cb1e30a307b99c1063cb2523e9e9253eb32f1ed2 Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:02:14 -0400 Subject: [PATCH 181/379] ci: update labels and release drafter (#387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Updates to use githubs built in release notes and using the following labels. `**NOTE**: The labeler job is dry running on the PR to show what it will do, doesn't execute until we merge.` ### ⚠️ Breaking Change breaking-change: any changes that break end users or downstream workflows ### 🐛 Bug Fixes bugfix: changes that fix a existing bug ### 🚀 New Features new-feature: changes that add new features such as endpoints or tools ### 💡 Improvements improvement: changes that improve existing features or reflect small API changes ### 🧪 Testing Improvements testing: improvements to the testing workflows ### ⚙️ Repo/CI Improvements repo-ci-improvement: improvements to the CI workflow, like this PR! ### 📖 Documentation documentation: updates to the package/repo documentation or wiki ### 📦 Dependency Updates dependencies: Used by dependabot mostly ### Ignore For Release ignore-for-release: for PRs you dont want rendered in the changelog, usually the release merge to main --- .github/labels.yml | 38 +++++++++++++++++++++++++++ .github/release-drafter.yml | 21 --------------- .github/release.yml | 32 ++++++++++++++++++++++ .github/workflows/labeler.yml | 31 ++++++++++++++++++++++ .github/workflows/release-drafter.yml | 16 ----------- 5 files changed, 101 insertions(+), 37 deletions(-) create mode 100644 .github/labels.yml delete mode 100644 .github/release-drafter.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/labeler.yml delete mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 000000000..2a28fc812 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,38 @@ +# PR Labels +- name: new-feature + description: for new features in the changelog. + color: 225fee +- name: improvement + description: for improvements in existing functionality in the changelog. + color: 22ee47 +- name: repo-ci-improvement + description: for improvements in the repository or CI workflow in the changelog. + color: c922ee +- name: bugfix + description: for any bug fixes in the changelog. + color: ed8e21 +- name: documentation + description: for updates to the documentation in the changelog. + color: d3e1e6 +- name: dependencies + description: dependency updates usually from dependabot + color: 5c9dff +- name: testing + description: for updates to the testing suite in the changelog. + color: 933ac9 +- name: breaking-change + description: for breaking changes in the changelog. + color: ff0000 +- name: ignore-for-release + description: PRs you do not want to render in the changelog + color: 7b8eac +- name: do-not-merge + description: PRs that should not be merged until the commented issue is resolved + color: eb1515 +# Issue Labels +- name: enhancement + description: issues that request a enhancement + color: 22ee47 +- name: bug + description: issues that report a bug + color: ed8e21 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml deleted file mode 100644 index 794521367..000000000 --- a/.github/release-drafter.yml +++ /dev/null @@ -1,21 +0,0 @@ -name-template: 'v$NEXT_PATCH_VERSION' -tag-template: 'v$NEXT_PATCH_VERSION' -categories: - - title: '🚀 Added' - label: 'added-feature' - - title: '🧰 Changed' - label: 'changed' - - title: "⚠️ Deprecated" - label: "deprecated" - - title: "⚠️ Removed" - label: "removed" - - title: '🐛 Bug Fixes' - label: 'bugfix' - - title: "⚠️ Security" - label: "security" -change-template: '- $TITLE @$AUTHOR (#$NUMBER)' -no-changes-template: "- No changes" -template: | - ## Changes - - $CHANGES \ No newline at end of file diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..8417f9fb9 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,32 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: ⚠️ Breaking Change + labels: + - breaking-change + - title: 🐛 Bug Fixes + labels: + - bugfix + - title: 🚀 New Features + labels: + - new-feature + - title: 💡 Improvements + labels: + - improvement + - title: 🧪 Testing Improvements + labels: + - testing + - title: ⚙️ Repo/CI Improvements + labels: + - repo-ci-improvement + - title: 📖 Documentation + labels: + - documentation + - title: 📦 Dependency Updates + labels: + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 000000000..da42b7e4a --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,31 @@ +name: labeler + +on: + push: + branches: + - 'main' + paths: + - '.github/labels.yml' + - '.github/workflows/labeler.yml' + pull_request: + paths: + - '.github/labels.yml' + - '.github/workflows/labeler.yml' + +jobs: + labeler: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Run Labeler + uses: crazy-max/ghaction-github-labeler@de749cf181958193cb7debf1a9c5bb28922f3e1b + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + yaml-file: .github/labels.yml + dry-run: ${{ github.event_name == 'pull_request' }} + exclude: | + help* + *issue diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml deleted file mode 100644 index b4207e7d4..000000000 --- a/.github/workflows/release-drafter.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Release Drafter - -on: - push: - branches: - - main # Preemptive for branch change - -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - - uses: release-drafter/release-drafter@569eb7ee3a85817ab916c8f8ff03a5bd96c9c83e # pin@v5 - with: - config-name: release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 0951c3444d3fe18484e53c95e11f975b28727d4f Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 1 Apr 2024 09:52:17 -0400 Subject: [PATCH 182/379] new: Add site_type to `Region` (#386) * add site_type * lint --- linode_api4/objects/region.py | 1 + test/fixtures/regions.json | 33 ++++++++++++------- .../linode_client/test_linode_client.py | 15 ++++++++- test/unit/linode_client_test.py | 1 + test/unit/objects/region_test.py | 1 + 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 7f48ea846..a20d5ee94 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -22,6 +22,7 @@ class Region(Base): "status": Property(), "resolvers": Property(), "label": Property(), + "site_type": Property(), } @property diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index ab848b3f8..9200455d4 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -13,7 +13,8 @@ "ipv4": "172.105.34.5,172.105.35.5,172.105.36.5,172.105.37.5,172.105.38.5,172.105.39.5,172.105.40.5,172.105.41.5,172.105.42.5,172.105.43.5", "ipv6": "2400:8904::f03c:91ff:fea5:659,2400:8904::f03c:91ff:fea5:9282,2400:8904::f03c:91ff:fea5:b9b3,2400:8904::f03c:91ff:fea5:925a,2400:8904::f03c:91ff:fea5:22cb,2400:8904::f03c:91ff:fea5:227a,2400:8904::f03c:91ff:fea5:924c,2400:8904::f03c:91ff:fea5:f7e2,2400:8904::f03c:91ff:fea5:2205,2400:8904::f03c:91ff:fea5:9207" }, - "label": "label1" + "label": "label1", + "site_type": "core" }, { "id": "ca-central", @@ -28,7 +29,8 @@ "ipv4": "172.105.0.5,172.105.3.5,172.105.4.5,172.105.5.5,172.105.6.5,172.105.7.5,172.105.8.5,172.105.9.5,172.105.10.5,172.105.11.5", "ipv6": "2600:3c04::f03c:91ff:fea9:f63,2600:3c04::f03c:91ff:fea9:f6d,2600:3c04::f03c:91ff:fea9:f80,2600:3c04::f03c:91ff:fea9:f0f,2600:3c04::f03c:91ff:fea9:f99,2600:3c04::f03c:91ff:fea9:fbd,2600:3c04::f03c:91ff:fea9:fdd,2600:3c04::f03c:91ff:fea9:fe2,2600:3c04::f03c:91ff:fea9:f68,2600:3c04::f03c:91ff:fea9:f4a" }, - "label": "label2" + "label": "label2", + "site_type": "core" }, { "id": "ap-southeast", @@ -43,7 +45,8 @@ "ipv4": "172.105.166.5,172.105.169.5,172.105.168.5,172.105.172.5,172.105.162.5,172.105.170.5,172.105.167.5,172.105.171.5,172.105.181.5,172.105.161.5", "ipv6": "2400:8907::f03c:92ff:fe6e:ec8,2400:8907::f03c:92ff:fe6e:98e4,2400:8907::f03c:92ff:fe6e:1c58,2400:8907::f03c:92ff:fe6e:c299,2400:8907::f03c:92ff:fe6e:c210,2400:8907::f03c:92ff:fe6e:c219,2400:8907::f03c:92ff:fe6e:1c5c,2400:8907::f03c:92ff:fe6e:c24e,2400:8907::f03c:92ff:fe6e:e6b,2400:8907::f03c:92ff:fe6e:e3d" }, - "label": "label3" + "label": "label3", + "site_type": "core" }, { "id": "us-central", @@ -58,7 +61,8 @@ "ipv4": "72.14.179.5,72.14.188.5,173.255.199.5,66.228.53.5,96.126.122.5,96.126.124.5,96.126.127.5,198.58.107.5,198.58.111.5,23.239.24.5", "ipv6": "2600:3c00::2,2600:3c00::9,2600:3c00::7,2600:3c00::5,2600:3c00::3,2600:3c00::8,2600:3c00::6,2600:3c00::4,2600:3c00::c,2600:3c00::b" }, - "label": "label4" + "label": "label4", + "site_type": "core" }, { "id": "us-west", @@ -73,7 +77,8 @@ "ipv4": "173.230.145.5,173.230.147.5,173.230.155.5,173.255.212.5,173.255.219.5,173.255.241.5,173.255.243.5,173.255.244.5,74.207.241.5,74.207.242.5", "ipv6": "2600:3c01::2,2600:3c01::9,2600:3c01::5,2600:3c01::7,2600:3c01::3,2600:3c01::8,2600:3c01::4,2600:3c01::b,2600:3c01::c,2600:3c01::6" }, - "label": "label5" + "label": "label5", + "site_type": "core" }, { "id": "us-southeast", @@ -88,7 +93,8 @@ "ipv4": "74.207.231.5,173.230.128.5,173.230.129.5,173.230.136.5,173.230.140.5,66.228.59.5,66.228.62.5,50.116.35.5,50.116.41.5,23.239.18.5", "ipv6": "2600:3c02::3,2600:3c02::5,2600:3c02::4,2600:3c02::6,2600:3c02::c,2600:3c02::7,2600:3c02::2,2600:3c02::9,2600:3c02::8,2600:3c02::b" }, - "label": "label6" + "label": "label6", + "site_type": "core" }, { "id": "us-east", @@ -104,7 +110,8 @@ "ipv4": "66.228.42.5,96.126.106.5,50.116.53.5,50.116.58.5,50.116.61.5,50.116.62.5,66.175.211.5,97.107.133.4,207.192.69.4,207.192.69.5", "ipv6": "2600:3c03::7,2600:3c03::4,2600:3c03::9,2600:3c03::6,2600:3c03::3,2600:3c03::c,2600:3c03::5,2600:3c03::b,2600:3c03::2,2600:3c03::8" }, - "label": "label7" + "label": "label7", + "site_type": "core" }, { "id": "eu-west", @@ -119,7 +126,8 @@ "ipv4": "178.79.182.5,176.58.107.5,176.58.116.5,176.58.121.5,151.236.220.5,212.71.252.5,212.71.253.5,109.74.192.20,109.74.193.20,109.74.194.20", "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2" }, - "label": "label8" + "label": "label8", + "site_type": "core" }, { "id": "ap-south", @@ -135,7 +143,8 @@ "ipv4": "139.162.11.5,139.162.13.5,139.162.14.5,139.162.15.5,139.162.16.5,139.162.21.5,139.162.27.5,103.3.60.18,103.3.60.19,103.3.60.20", "ipv6": "2400:8901::5,2400:8901::4,2400:8901::b,2400:8901::3,2400:8901::9,2400:8901::2,2400:8901::8,2400:8901::7,2400:8901::c,2400:8901::6" }, - "label": "label9" + "label": "label9", + "site_type": "core" }, { "id": "eu-central", @@ -151,7 +160,8 @@ "ipv4": "139.162.130.5,139.162.131.5,139.162.132.5,139.162.133.5,139.162.134.5,139.162.135.5,139.162.136.5,139.162.137.5,139.162.138.5,139.162.139.5", "ipv6": "2a01:7e01::5,2a01:7e01::9,2a01:7e01::7,2a01:7e01::c,2a01:7e01::2,2a01:7e01::4,2a01:7e01::3,2a01:7e01::6,2a01:7e01::b,2a01:7e01::8" }, - "label": "label10" + "label": "label10", + "site_type": "core" }, { "id": "ap-northeast", @@ -166,7 +176,8 @@ "ipv4": "139.162.66.5,139.162.67.5,139.162.68.5,139.162.69.5,139.162.70.5,139.162.71.5,139.162.72.5,139.162.73.5,139.162.74.5,139.162.75.5", "ipv6": "2400:8902::3,2400:8902::6,2400:8902::c,2400:8902::4,2400:8902::2,2400:8902::8,2400:8902::7,2400:8902::5,2400:8902::b,2400:8902::9" }, - "label": "label11" + "label": "label11", + "site_type": "core" } ], "page": 1, diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index d7aee9d8c..2d7994a20 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -5,7 +5,7 @@ import pytest from linode_api4 import ApiError, LinodeClient -from linode_api4.objects import ConfigInterface, ObjectStorageKeys +from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region @pytest.fixture(scope="session", autouse=True) @@ -66,6 +66,19 @@ def test_get_domains(test_linode_client, test_domain): assert domain.domain in dom_list +@pytest.mark.smoke +def test_get_regions(test_linode_client): + client = test_linode_client + regions = client.regions() + + region_list = [r.id for r in regions] + + test_region = Region(client, "us-east") + + assert test_region.id in region_list + assert test_region.site_type in ["core", "edge"] + + @pytest.mark.smoke def test_image_create(setup_client_and_linode): client = setup_client_and_linode[0] diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 3f331c9b7..3facd2e95 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -74,6 +74,7 @@ def test_get_regions(self): self.assertIsNotNone(region.resolvers) self.assertIsNotNone(region.resolvers.ipv4) self.assertIsNotNone(region.resolvers.ipv6) + self.assertEqual(region.site_type, "core") def test_get_images(self): r = self.client.images() diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 9c954a3da..77b7ee2a9 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -22,6 +22,7 @@ def test_get_region(self): self.assertEqual(region.label, "label7") self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) + self.assertEqual(region.site_type, "core") def test_list_availability(self): """ From c8f4bd59dcf2207f81a291d43c6c7fe1ccd5fbc5 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 8 Apr 2024 11:57:32 -0400 Subject: [PATCH 183/379] new: List ips under a specific VPC (#391) * list ips under a vpc * fix --- linode_api4/objects/vpc.py | 21 ++++++++++++++++ test/fixtures/vpcs_123456_ips.json | 34 ++++++++++++++++++++++++++ test/integration/conftest.py | 16 ------------ test/integration/models/test_linode.py | 9 ++++++- test/unit/objects/vpc_test.py | 25 +++++++++++++++++++ 5 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 test/fixtures/vpcs_123456_ips.json diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 989c542ee..682b7a0ab 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -4,6 +4,7 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, Property, Region from linode_api4.objects.serializable import JSONObject +from linode_api4.paginated_list import PaginatedList @dataclass @@ -97,3 +98,23 @@ def subnet_create( d = VPCSubnet(self._client, result["id"], self.id, result) return d + + @property + def ips(self, *filters) -> PaginatedList: + """ + Get all the IP addresses under this VPC. + + API Documentation: TODO + + :returns: A list of VPCIPAddresses the acting user can access. + :rtype: PaginatedList of VPCIPAddress + """ + + # need to avoid circular import + from linode_api4.objects import ( # pylint: disable=import-outside-toplevel + VPCIPAddress, + ) + + return self._client._get_and_filter( + VPCIPAddress, *filters, endpoint="/vpcs/{}/ips".format(self.id) + ) diff --git a/test/fixtures/vpcs_123456_ips.json b/test/fixtures/vpcs_123456_ips.json new file mode 100644 index 000000000..70b4b8a60 --- /dev/null +++ b/test/fixtures/vpcs_123456_ips.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "address": "10.0.0.2", + "address_range": null, + "vpc_id": 123456, + "subnet_id": 654321, + "region": "us-ord", + "linode_id": 111, + "config_id": 222, + "interface_id": 333, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "address": "10.0.0.3", + "address_range": null, + "vpc_id": 41220, + "subnet_id": 41184, + "region": "us-ord", + "linode_id": 56323949, + "config_id": 59467106, + "interface_id": 1248358, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + } + ] +} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 93cff7867..03295e59c 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -316,22 +316,6 @@ def create_vpc_with_subnet_and_linode( instance.delete() -@pytest.fixture(scope="session") -def create_vpc(test_linode_client): - client = test_linode_client - - timestamp = str(int(time.time_ns() % 10**10)) - - vpc = client.vpcs.create( - "pythonsdk-" + timestamp, - get_region(test_linode_client, {"VPCs"}), - description="test description", - ) - yield vpc - - vpc.delete() - - @pytest.fixture(scope="session") def create_multiple_vpcs(test_linode_client): client = test_linode_client diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index 40d1e735f..ce122226a 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -15,7 +15,6 @@ ConfigInterface, ConfigInterfaceIPv4, Disk, - Image, Instance, Type, ) @@ -643,6 +642,14 @@ def test_create_vpc( ) assert all_vpc_ips[0].dict == vpc_ip.dict + # Test getting the ips under this specific VPC + vpc_ips = vpc.ips + + assert len(vpc_ips) > 0 + assert vpc_ips[0].vpc_id == vpc.id + assert vpc_ips[0].linode_id == linode.id + assert vpc_ips[0].nat_1_1 == linode.ips.ipv4.public[0].address + def test_update_vpc( self, linode_for_network_interface_tests, diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 830e9fb9f..7e4963d33 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -173,3 +173,28 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): self.assertEqual(subnet.linodes[0].id, 12345) self.assertEqual(subnet.created, expected_dt) self.assertEqual(subnet.updated, expected_dt) + + def test_list_vpc_ips(self): + """ + Test that the ips under a specific VPC can be listed. + """ + vpc = VPC(self.client, 123456) + vpc_ips = vpc.ips + + self.assertGreater(len(vpc_ips), 0) + + vpc_ip = vpc_ips[0] + + self.assertEqual(vpc_ip.vpc_id, vpc.id) + self.assertEqual(vpc_ip.address, "10.0.0.2") + self.assertEqual(vpc_ip.address_range, None) + self.assertEqual(vpc_ip.subnet_id, 654321) + self.assertEqual(vpc_ip.region, "us-ord") + self.assertEqual(vpc_ip.linode_id, 111) + self.assertEqual(vpc_ip.config_id, 222) + self.assertEqual(vpc_ip.interface_id, 333) + self.assertEqual(vpc_ip.active, True) + self.assertEqual(vpc_ip.nat_1_1, None) + self.assertEqual(vpc_ip.gateway, "10.0.0.1") + self.assertEqual(vpc_ip.prefix, 8) + self.assertEqual(vpc_ip.subnet_mask, "255.0.0.0") From bcb66cb30270f7815fdcf37f3aa23dc2bc64b5ed Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:21:20 -0400 Subject: [PATCH 184/379] Annotate set fields as unordered (#390) --- linode_api4/objects/account.py | 4 ++-- linode_api4/objects/base.py | 6 ++++++ linode_api4/objects/database.py | 6 +++--- linode_api4/objects/domain.py | 6 +++--- linode_api4/objects/image.py | 4 +++- linode_api4/objects/linode.py | 8 +++++--- linode_api4/objects/lke.py | 4 ++-- linode_api4/objects/networking.py | 8 +++++--- linode_api4/objects/nodebalancer.py | 4 ++-- linode_api4/objects/region.py | 2 +- linode_api4/objects/volume.py | 2 +- linode_api4/objects/vpc.py | 2 +- 12 files changed, 34 insertions(+), 22 deletions(-) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index f50c4da32..349331be3 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -43,7 +43,7 @@ class Account(Base): "zip": Property(mutable=True), "address_2": Property(mutable=True), "tax_id": Property(mutable=True), - "capabilities": Property(), + "capabilities": Property(unordered=True), "credit_card": Property(), "active_promotions": Property(), "active_since": Property(), @@ -670,5 +670,5 @@ class AccountAvailability(Base): properties = { "region": Property(identifier=True), - "unavailable": Property(), + "unavailable": Property(unordered=True), } diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 3e42e098a..2021ef3b7 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -32,6 +32,7 @@ def __init__( id_relationship=False, slug_relationship=False, nullable=False, + unordered=False, json_object=None, ): """ @@ -50,6 +51,10 @@ def __init__( (This should be used on fields ending with '_id' only) slug_relationship - This property is a slug related for a given type. nullable - This property can be explicitly null on PUT requests. + unordered - The order of this property is not significant. + NOTE: This field is currently only for annotations purposes + and does not influence any update or decoding/encoding logic. + json_object - The JSONObject class this property should be decoded into. """ self.mutable = mutable self.identifier = identifier @@ -60,6 +65,7 @@ def __init__( self.id_relationship = id_relationship self.slug_relationship = slug_relationship self.nullable = nullable + self.unordered = unordered self.json_class = json_object diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index efddee0c7..f71115758 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -129,7 +129,7 @@ class MySQLDatabase(Base): properties = { "id": Property(identifier=True), "label": Property(mutable=True), - "allow_list": Property(mutable=True), + "allow_list": Property(mutable=True, unordered=True), "backups": Property(derived_class=MySQLDatabaseBackup), "cluster_size": Property(), "created": Property(is_datetime=True), @@ -262,7 +262,7 @@ class PostgreSQLDatabase(Base): properties = { "id": Property(identifier=True), "label": Property(mutable=True), - "allow_list": Property(mutable=True), + "allow_list": Property(mutable=True, unordered=True), "backups": Property(derived_class=PostgreSQLDatabaseBackup), "cluster_size": Property(), "created": Property(is_datetime=True), @@ -404,7 +404,7 @@ class Database(Base): properties = { "id": Property(), "label": Property(), - "allow_list": Property(), + "allow_list": Property(unordered=True), "cluster_size": Property(), "created": Property(), "encrypted": Property(), diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index 38778c78d..aeca7d837 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -49,14 +49,14 @@ class Domain(Base): "status": Property(mutable=True), "soa_email": Property(mutable=True), "retry_sec": Property(mutable=True), - "master_ips": Property(mutable=True), - "axfr_ips": Property(mutable=True), + "master_ips": Property(mutable=True, unordered=True), + "axfr_ips": Property(mutable=True, unordered=True), "expire_sec": Property(mutable=True), "refresh_sec": Property(mutable=True), "ttl_sec": Property(mutable=True), "records": Property(derived_class=DomainRecord), "type": Property(mutable=True), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), } def record_create(self, record_type, **kwargs): diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 606743ce0..a919d25e0 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -25,5 +25,7 @@ class Image(Base): "vendor": Property(), "size": Property(), "deprecated": Property(), - "capabilities": Property(), + "capabilities": Property( + unordered=True, + ), } diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 9477dd105..f459f5918 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -642,11 +642,11 @@ class Instance(Base): "configs": Property(derived_class=Config), "type": Property(slug_relationship=Type), "backups": Property(mutable=True), - "ipv4": Property(), + "ipv4": Property(unordered=True), "ipv6": Property(), "hypervisor": Property(), "specs": Property(), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), "host_uuid": Property(), "watchdog_enabled": Property(mutable=True), "has_user_data": Property(), @@ -1745,7 +1745,9 @@ class StackScript(Base): "created": Property(is_datetime=True), "deployments_active": Property(), "script": Property(mutable=True), - "images": Property(mutable=True), # TODO make slug_relationship + "images": Property( + mutable=True, unordered=True + ), # TODO make slug_relationship "deployments_total": Property(), "description": Property(mutable=True), "updated": Property(is_datetime=True), diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 2e24b76f7..f1769685a 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -74,7 +74,7 @@ class LKENodePool(DerivedBase): volatile=True ), # this is formatted in _populate below "autoscaler": Property(mutable=True), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), } def _populate(self, json): @@ -121,7 +121,7 @@ class LKECluster(Base): "id": Property(identifier=True), "created": Property(is_datetime=True), "label": Property(mutable=True), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), "updated": Property(is_datetime=True), "region": Property(slug_relationship=Region), "k8s_version": Property(slug_relationship=KubeVersion, mutable=True), diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 17d0ec4c6..dac295360 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -34,7 +34,9 @@ class IPv6Range(Base): "region": Property(slug_relationship=Region), "prefix": Property(), "route_target": Property(), - "linodes": Property(), + "linodes": Property( + unordered=True, + ), "is_bgp": Property(), } @@ -151,7 +153,7 @@ class VLAN(Base): properties = { "label": Property(identifier=True), "created": Property(is_datetime=True), - "linodes": Property(), + "linodes": Property(unordered=True), "region": Property(slug_relationship=Region), } @@ -189,7 +191,7 @@ class Firewall(Base): properties = { "id": Property(identifier=True), "label": Property(mutable=True), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), "status": Property(mutable=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 99c88f3c5..c6f161ac8 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -34,7 +34,7 @@ class NodeBalancerNode(DerivedBase): "weight": Property(mutable=True), "mode": Property(mutable=True), "status": Property(), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), } def __init__(self, client, id, parent_id, nodebalancer_id=None, json=None): @@ -217,7 +217,7 @@ class NodeBalancer(Base): "region": Property(slug_relationship=Region), "configs": Property(derived_class=NodeBalancerConfig), "transfer": Property(), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), } # create derived objects diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index a20d5ee94..ab77074d0 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -18,7 +18,7 @@ class Region(Base): properties = { "id": Property(identifier=True), "country": Property(), - "capabilities": Property(), + "capabilities": Property(unordered=True), "status": Property(), "resolvers": Property(), "label": Property(), diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index d572c12f5..365ceb2d3 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -21,7 +21,7 @@ class Volume(Base): "size": Property(), "status": Property(), "region": Property(slug_relationship=Region), - "tags": Property(mutable=True), + "tags": Property(mutable=True, unordered=True), "filesystem_path": Property(), "hardware_type": Property(), "linode_label": Property(), diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 682b7a0ab..3f80b0925 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -34,7 +34,7 @@ class VPCSubnet(DerivedBase): "id": Property(identifier=True), "label": Property(mutable=True), "ipv4": Property(), - "linodes": Property(json_object=VPCSubnetLinode), + "linodes": Property(json_object=VPCSubnetLinode, unordered=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } From 37fbc5e5f53d7d108318e096c9460f123ea8fb4a Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 9 Apr 2024 13:51:09 -0400 Subject: [PATCH 185/379] Serialize `Base` objects in `MappedObject` (#389) --- linode_api4/objects/base.py | 10 +++++++-- test/fixtures/testmappedobj1.json | 3 +++ test/unit/objects/mapped_object_test.py | 29 ++++++++++++++++++++++--- 3 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/testmappedobj1.json diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 2021ef3b7..9d86b8808 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -109,9 +109,15 @@ def dict(self): result[k] = v.dict elif isinstance(v, list): result[k] = [ - item.dict if isinstance(item, cls) else item for item in v + ( + item.dict + if isinstance(item, cls) + else item._raw_json if isinstance(item, Base) else item + ) + for item in v ] - + elif isinstance(v, Base): + result[k] = v._raw_json return result diff --git a/test/fixtures/testmappedobj1.json b/test/fixtures/testmappedobj1.json new file mode 100644 index 000000000..0914c1ded --- /dev/null +++ b/test/fixtures/testmappedobj1.json @@ -0,0 +1,3 @@ +{ + "bar": "bar" +} \ No newline at end of file diff --git a/test/unit/objects/mapped_object_test.py b/test/unit/objects/mapped_object_test.py index 87284af8f..2d83008ae 100644 --- a/test/unit/objects/mapped_object_test.py +++ b/test/unit/objects/mapped_object_test.py @@ -1,9 +1,9 @@ -from unittest import TestCase +from test.unit.base import ClientBaseCase -from linode_api4 import MappedObject +from linode_api4.objects import Base, MappedObject, Property -class MappedObjectCase(TestCase): +class MappedObjectCase(ClientBaseCase): def test_mapped_object_dict(self): test_dict = { "key1": 1, @@ -19,3 +19,26 @@ def test_mapped_object_dict(self): mapped_obj = MappedObject(**test_dict) self.assertEqual(mapped_obj.dict, test_dict) + + def test_mapped_object_dict(self): + test_property_name = "bar" + test_property_value = "bar" + + class Foo(Base): + api_endpoint = "/testmappedobj1" + id_attribute = test_property_name + properties = { + test_property_name: Property(mutable=True), + } + + foo = Foo(self.client, test_property_value) + foo._api_get() + + expected_dict = { + "foo": { + test_property_name: test_property_value, + } + } + + mapped_obj = MappedObject(foo=foo) + self.assertEqual(mapped_obj.dict, expected_dict) From 22e1778a29e45123f616a7845105d696a8a7b61e Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:55:48 -0700 Subject: [PATCH 186/379] test: remove unnecessary warnings when running integration tests (#392) * Remove warnings and additional fixture for stability * make format * address other warning --- Makefile | 2 +- test/integration/conftest.py | 7 +++++++ test/integration/linode_client/test_linode_client.py | 5 +---- test/integration/models/test_linode.py | 3 --- tod_scripts | 2 +- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index 0589eec02..32d31cf20 100644 --- a/Makefile +++ b/Makefile @@ -75,4 +75,4 @@ testunit: .PHONY: smoketest smoketest: - $(PYTHON) -m pytest -m smoke test/integration --disable-warnings \ No newline at end of file + $(PYTHON) -m pytest -m smoke test/integration \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 03295e59c..3ba7d2b40 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -341,3 +341,10 @@ def create_multiple_vpcs(test_linode_client): vpc_1.delete() vpc_2.delete() + + +@pytest.mark.smoke +def pytest_configure(config): + config.addinivalue_line( + "markers", "smoke: mark test as part of smoke test suite" + ) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 2d7994a20..bc5d31292 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -31,7 +31,7 @@ def test_get_account(setup_client_and_linode): assert re.search("^$|[a-zA-Z]+", account.first_name) assert re.search("^$|[a-zA-Z]+", account.last_name) assert re.search( - "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", account.email + "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+$", account.email ) assert re.search("^$|[a-zA-Z0-9]+", account.address_1) assert re.search("^$|[a-zA-Z0-9]+", account.address_2) @@ -401,9 +401,6 @@ def test_keys_create(test_linode_client, ssh_keys_object_storage): # NetworkingGroupTests -# TODO:: creating vlans -# def test_get_vlans(): - @pytest.fixture def create_firewall_with_inbound_outbound_rules(test_linode_client): diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index ce122226a..ba44e157f 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -114,7 +114,6 @@ def linode_for_disk_tests(test_linode_client): linode_instance.delete() -@pytest.mark.smoke @pytest.fixture def create_linode_for_long_running_tests(test_linode_client): client = test_linode_client @@ -372,7 +371,6 @@ def wait_for_disk_status(disk: Disk, timeout): raise TimeoutError("Wait for condition timeout error") -@pytest.mark.dependency() def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): linode = linode_for_disk_tests @@ -396,7 +394,6 @@ def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): assert dup_disk.linode_id == linode.id -@pytest.mark.dependency(depends=["test_disk_resize_and_duplicate"]) def test_linode_create_disk(test_linode_client, linode_for_disk_tests): linode = test_linode_client.load(Instance, linode_for_disk_tests.id) diff --git a/tod_scripts b/tod_scripts index eec4b9955..41b85dd2c 160000 --- a/tod_scripts +++ b/tod_scripts @@ -1 +1 @@ -Subproject commit eec4b99557cef6f40e8b5b7de00357dc49fb041c +Subproject commit 41b85dd2c5588b5b343b8ee365b2f4f196cd9a7f From a25850f21f387454d9e4cebb9c5abe9257ae0e5b Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:51:28 -0700 Subject: [PATCH 187/379] add integration workflow for main and dev (#393) --- .github/workflows/e2e-test.yml | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/e2e-test.yml diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 000000000..166b70e75 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,70 @@ +name: Integration Tests + +on: + workflow_dispatch: null + push: + branches: + - main + - dev + +jobs: + integration-tests: + runs-on: ubuntu-latest + env: + EXIT_STATUS: 0 + steps: + - name: Clone Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python deps + run: pip install -U setuptools wheel boto3 certifi + + - name: Install Python SDK + run: make dev-install + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Integration tests + run: | + timestamp=$(date +'%Y%m%d%H%M') + report_filename="${timestamp}_sdk_test_report.xml" + status=0 + if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then + echo "EXIT_STATUS=1" >> $GITHUB_ENV + fi + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + - name: Add additional information to XML report + run: | + filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + python tod_scripts/add_to_xml_test_report.py \ + --branch_name "${GITHUB_REF#refs/*/}" \ + --gha_run_id "$GITHUB_RUN_ID" \ + --gha_run_number "$GITHUB_RUN_NUMBER" \ + --xmlfile "${filename}" + + - name: Upload test results + run: | + report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + python3 tod_scripts/test_report_upload_script.py "${report_filename}" + env: + LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} + LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + + - name: Test Execution Status Handler + run: | + if [[ "$EXIT_STATUS" != 0 ]]; then + echo "Test execution contains failure(s)" + exit $EXIT_STATUS + else + echo "Tests passed!" + fi \ No newline at end of file From b1c56a6bff4562f57f0e75dc26ab18b88e7565d4 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 16 Apr 2024 10:41:05 -0700 Subject: [PATCH 188/379] test: address intermittent test failures (#394) * address intermittent test failures and some warnings * format/lint * replace wait with polling * fix testcase --- test/integration/models/test_database.py | 6 +-- test/integration/models/test_linode.py | 62 +++++++++++++++++------- test/integration/models/test_lke.py | 28 +++++++++-- 3 files changed, 70 insertions(+), 26 deletions(-) diff --git a/test/integration/models/test_database.py b/test/integration/models/test_database.py index 0e14f5041..b9502abdc 100644 --- a/test/integration/models/test_database.py +++ b/test/integration/models/test_database.py @@ -101,9 +101,9 @@ def test_get_types(test_linode_client): client = test_linode_client types = client.database.types() - assert (types[0].type_class, "nanode") - assert (types[0].id, "g6-nanode-1") - assert (types[0].engines.mongodb[0].price.monthly, 15) + assert "nanode" in types[0].type_class + assert "g6-nanode-1" in types[0].id + assert types[0].engines.mongodb[0].price.monthly == 15 def test_get_engines(test_linode_client): diff --git a/test/integration/models/test_linode.py b/test/integration/models/test_linode.py index ba44e157f..a749baad4 100644 --- a/test/integration/models/test_linode.py +++ b/test/integration/models/test_linode.py @@ -6,9 +6,10 @@ wait_for_condition, ) +import polling import pytest -from linode_api4 import VPCIPAddress +from linode_api4 import LinodeClient, VPCIPAddress from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -84,7 +85,7 @@ def linode_for_network_interface_tests(test_linode_client): linode_instance.delete() -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture def linode_for_disk_tests(test_linode_client): client = test_linode_client available_regions = client.regions() @@ -94,21 +95,24 @@ def linode_for_disk_tests(test_linode_client): linode_instance, password = client.linode.instance_create( "g6-nanode-1", chosen_region, - image="linode/debian10", + image="linode/alpine3.19", label=label + "_long_tests", ) - time.sleep(10) - # Provisioning time wait_for_condition(10, 300, get_status, linode_instance, "running") - time.sleep(10) - linode_instance.shutdown() wait_for_condition(10, 100, get_status, linode_instance, "offline") + # Now it allocates 100% disk space hence need to clear some space for tests + linode_instance.disks[1].delete() + + test_linode_client.polling.event_poller_create( + "linode", "disk_delete", entity_id=linode_instance.id + ) + yield linode_instance linode_instance.delete() @@ -138,6 +142,10 @@ def get_status(linode: Instance, status: str): return linode.status == status +def instance_type_condition(linode: Instance, type: str): + return type in str(linode.type) + + def test_get_linode(test_linode_client, linode_with_volume_firewall): linode = test_linode_client.load(Instance, linode_with_volume_firewall.id) @@ -303,6 +311,7 @@ def test_linode_resize_with_class( def test_linode_resize_with_migration_type( + test_linode_client, create_linode_for_long_running_tests, ): linode = create_linode_for_long_running_tests @@ -311,18 +320,32 @@ def test_linode_resize_with_migration_type( wait_for_condition(10, 100, get_status, linode, "running") time.sleep(5) - res = linode.resize(new_type="g6-standard-1", migration_type=m_type) - assert res + assert "g6-nanode-1" in str(linode.type) + assert linode.specs.disk == 25600 - wait_for_condition(10, 300, get_status, linode, "resizing") + res = linode.resize(new_type="g6-standard-1", migration_type=m_type) - assert linode.status == "resizing" + if res: + # there is no resizing state in warm migration anymore hence wait for resizing and poll event + test_linode_client.polling.event_poller_create( + "linode", "linode_resize", entity_id=linode.id + ).wait_for_next_event_finished(interval=5) + + wait_for_condition( + 10, + 100, + get_status, + linode, + "running", + ) + else: + raise ApiError - # Takes about 3-5 minute to resize, sometimes longer... - wait_for_condition(30, 600, get_status, linode, "running") + # reload resized linode + resized_linode = test_linode_client.load(Instance, linode.id) - assert linode.status == "running" + assert resized_linode.specs.disk == 51200 def test_linode_boot_with_config(create_linode): @@ -376,10 +399,9 @@ def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): disk = linode.disks[0] - disk.resize(5000) + send_request_when_resource_available(300, disk.resize, 5000) - # Using hard sleep instead of wait as the status shows ready when it is resizing - time.sleep(120) + time.sleep(100) disk = test_linode_client.load(Disk, linode.disks[0].id, linode.id) @@ -397,7 +419,11 @@ def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): def test_linode_create_disk(test_linode_client, linode_for_disk_tests): linode = test_linode_client.load(Instance, linode_for_disk_tests.id) - disk = linode.disk_create(size=500) + disk = send_request_when_resource_available( + 300, + linode.disk_create, + size=500, + ) wait_for_disk_status(disk, 120) diff --git a/test/integration/models/test_lke.py b/test/integration/models/test_lke.py index 04b479e8e..1cff9ec44 100644 --- a/test/integration/models/test_lke.py +++ b/test/integration/models/test_lke.py @@ -95,7 +95,7 @@ def test_lke_node_delete(lke_cluster): def test_lke_node_recycle(test_linode_client, lke_cluster): cluster = test_linode_client.load(LKECluster, lke_cluster.id) - node = cluster.pools[0].nodes[0] + node_id = cluster.pools[0].nodes[0].id send_request_when_resource_available(300, cluster.node_recycle, node_id) @@ -106,9 +106,18 @@ def test_lke_node_recycle(test_linode_client, lke_cluster): assert node.status == "not_ready" # wait for provisioning - wait_for_condition(10, 300, get_node_status, cluster, "ready") + wait_for_condition( + 10, + 500, + get_node_status, + test_linode_client.load(LKECluster, lke_cluster.id), + "ready", + ) - node = cluster.pools[0].nodes[0] + node_pool = test_linode_client.load( + LKENodePool, cluster.pools[0].id, cluster.id + ) + node = node_pool.nodes[0] assert node.status == "ready" @@ -117,9 +126,18 @@ def test_lke_cluster_nodes_recycle(test_linode_client, lke_cluster): send_request_when_resource_available(300, cluster.cluster_nodes_recycle) - wait_for_condition(5, 300, get_node_status, cluster, "not_ready") + wait_for_condition( + 5, + 300, + get_node_status, + test_linode_client.load(LKECluster, cluster.id), + "not_ready", + ) - node = cluster.pools[0].nodes[0] + node_pool = test_linode_client.load( + LKENodePool, cluster.pools[0].id, cluster.id + ) + node = node_pool.nodes[0] assert node.status == "not_ready" From 23d41fd0177729c7a16764cc4f9e92de3accab96 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 17 Apr 2024 10:20:34 -0400 Subject: [PATCH 189/379] new: Add `available` field to AccountAvailability class (#395) * Add available field to account availabilities response * Update docs --- linode_api4/groups/account.py | 4 +-- linode_api4/objects/account.py | 6 ++-- test/fixtures/account_availability.json | 33 ++++++++++++------- .../account_availability_us-east.json | 3 +- test/unit/objects/account_test.py | 12 +++++++ 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 55eab9436..8805c6416 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -487,10 +487,10 @@ def join_beta_program(self, beta: Union[str, BetaProgram]): def availabilities(self, *filters): """ - Returns a list of all available regions and the resources which are NOT available + Returns a list of all available regions and the resource types which are available to the account. - API doc: TBD + API doc: https://www.linode.com/docs/api/account/#region-service-availability :returns: a list of region availability information. :rtype: PaginatedList of AccountAvailability diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 349331be3..19ecd79a0 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -660,9 +660,10 @@ class AccountBetaProgram(Base): class AccountAvailability(Base): """ - The resources information in a region which are NOT available to an account. + Contains information about the resources available for a region under the + current account. - API doc: TBD + API doc: https://www.linode.com/docs/api/account/#region-service-availability """ api_endpoint = "/account/availability/{region}" @@ -671,4 +672,5 @@ class AccountAvailability(Base): properties = { "region": Property(identifier=True), "unavailable": Property(unordered=True), + "available": Property(unordered=True), } diff --git a/test/fixtures/account_availability.json b/test/fixtures/account_availability.json index a09feb1db..f308cb975 100644 --- a/test/fixtures/account_availability.json +++ b/test/fixtures/account_availability.json @@ -2,47 +2,58 @@ "data": [ { "region": "ap-west", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "ca-central", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "ap-southeast", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "us-central", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "us-west", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "us-southeast", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "us-east", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "Kubernetes"] }, { "region": "eu-west", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "Cloud Firewall"] }, { "region": "ap-south", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "eu-central", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "NodeBalancers"] }, { "region": "ap-northeast", - "unavailable": [] + "unavailable": [], + "available": ["Linodes"] } ], "page": 1, diff --git a/test/fixtures/account_availability_us-east.json b/test/fixtures/account_availability_us-east.json index 5bcceb526..765aeba6e 100644 --- a/test/fixtures/account_availability_us-east.json +++ b/test/fixtures/account_availability_us-east.json @@ -1,4 +1,5 @@ { "region": "us-east", - "unavailable": [] + "unavailable": [], + "available": ["Linodes", "Kubernetes"] } \ No newline at end of file diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 0f53240f4..b3f7be1e3 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -268,6 +268,17 @@ class AccountAvailabilityTest(ClientBaseCase): Test methods of the AccountAvailability """ + def test_account_availability_api_list(self): + with self.mock_get("/account/availability") as m: + availabilities = self.client.account.availabilities() + + for avail in availabilities: + assert avail.region is not None + assert len(avail.unavailable) == 0 + assert len(avail.available) > 0 + + self.assertEqual(m.call_url, "/account/availability") + def test_account_availability_api_get(self): region_id = "us-east" account_availability_url = "/account/availability/{}".format(region_id) @@ -276,5 +287,6 @@ def test_account_availability_api_get(self): availability = AccountAvailability(self.client, region_id) self.assertEqual(availability.region, region_id) self.assertEqual(availability.unavailable, []) + self.assertEqual(availability.available, ["Linodes", "Kubernetes"]) self.assertEqual(m.call_url, account_availability_url) From 6ba2d10c8935d9b372184a42ea0c89cefcf16d5a Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 18 Apr 2024 13:43:35 -0400 Subject: [PATCH 190/379] fix: Always load object values when converting MappedObjects to dict (#400) * Always load object values when converting MappedObjects to dict * Prevent overriding identifier attributes during construction * make format * Update unit test to cover parent_id issue --- linode_api4/objects/base.py | 27 ++++++++++++++++++++++++--- linode_api4/objects/dbase.py | 4 ++-- linode_api4/objects/object_storage.py | 4 ++-- test/unit/objects/linode_test.py | 8 ++++++++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 9d86b8808..45d18e4aa 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -1,5 +1,6 @@ import time from datetime import datetime, timedelta +from typing import Any, Dict, Optional from linode_api4.objects.serializable import JSONObject @@ -99,6 +100,18 @@ def _expand_vals(self, target, **vals): def __repr__(self): return "Mapping containing {}".format(vars(self).keys()) + @staticmethod + def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]: + if obj is None: + return None + + # If the object hasn't already been lazy-loaded, + # manually refresh it + if not getattr(obj, "_populated", False): + obj._api_get() + + return obj._raw_json + @property def dict(self): result = vars(self).copy() @@ -112,12 +125,17 @@ def dict(self): ( item.dict if isinstance(item, cls) - else item._raw_json if isinstance(item, Base) else item + else ( + self._flatten_base_subclass(item) + if isinstance(item, Base) + else item + ) ) for item in v ] elif isinstance(v, Base): - result[k] = v._raw_json + result[k] = self._flatten_base_subclass(v) + return result @@ -140,7 +158,10 @@ def __init__(self, client: object, id: object, json: object = {}) -> object: #: be updated on access. self._set("_raw_json", None) - for k in type(self).properties: + for k, v in type(self).properties.items(): + if v.identifier: + continue + self._set(k, None) self._set("id", id) diff --git a/linode_api4/objects/dbase.py b/linode_api4/objects/dbase.py index 3bd5bb7df..b6e288769 100644 --- a/linode_api4/objects/dbase.py +++ b/linode_api4/objects/dbase.py @@ -12,10 +12,10 @@ class DerivedBase(Base): parent_id_name = "parent_id" # override in child classes def __init__(self, client, id, parent_id, json={}): - Base.__init__(self, client, id, json=json) - self._set(type(self).parent_id_name, parent_id) + Base.__init__(self, client, id, json=json) + @classmethod def _api_get_derived(cls, parent, client): base_url = "{}/{}".format( diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index f1f040677..d9eb32433 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -31,10 +31,10 @@ class ObjectStorageBucket(DerivedBase): id_attribute = "label" properties = { - "cluster": Property(), + "cluster": Property(identifier=True), "created": Property(is_datetime=True), "hostname": Property(), - "label": Property(), + "label": Property(identifier=True), "objects": Property(), "size": Property(), } diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index c68dcfef6..9759bba41 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -554,6 +554,14 @@ def test_interface_ipv4(self): self.assertEqual(ipv4.vpc, "10.0.0.1") self.assertEqual(ipv4.nat_1_1, "any") + def test_config_devices_unwrap(self): + """ + Tests that config devices can be successfully converted to a dict. + """ + + inst = Instance(self.client, 123) + assert inst.configs[0].devices.dict.get("sda").get("id") == 12345 + class StackScriptTest(ClientBaseCase): """ From 9d4a6ee06c5a228d1bfd16d81845595cd5fcf684 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Thu, 18 Apr 2024 10:52:30 -0700 Subject: [PATCH 191/379] CI: Fix cross testing workflow (#401) * fix cross testing workflow * Update name --- .github/workflows/release-cross-repo-test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 0f484d9af..0850ed9ea 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -26,9 +26,6 @@ jobs: with: python-version: '3.10' - - name: Install linode_api4 - run: make install - - name: checkout repo uses: actions/checkout@v3 with: @@ -48,12 +45,15 @@ jobs: cd .ansible/collections/ansible_collections/linode/cloud make install + - name: Install linode_api4 # Need to install from source after all ansible dependencies have been installed + run: make install + - name: replace existing keys run: | cd .ansible/collections/ansible_collections/linode/cloud rm -rf ~/.ansible/test && mkdir -p ~/.ansible/test && ssh-keygen -m PEM -q -t rsa -N '' -f ~/.ansible/test/id_rsa - - name: run tests + - name: Run Ansible Tests run: | cd .ansible/collections/ansible_collections/linode/cloud make testall From b0920fd7dcbf01a0239cf26ca8b3336996c14174 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 18 Apr 2024 15:29:10 -0400 Subject: [PATCH 192/379] Serialize `JSONObject` in `MappedObject` (#399) --- linode_api4/objects/base.py | 4 +++- test/unit/objects/mapped_object_test.py | 24 ++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 45d18e4aa..abee4cdaa 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -124,7 +124,7 @@ def dict(self): result[k] = [ ( item.dict - if isinstance(item, cls) + if isinstance(item, (cls, JSONObject)) else ( self._flatten_base_subclass(item) if isinstance(item, Base) @@ -135,6 +135,8 @@ def dict(self): ] elif isinstance(v, Base): result[k] = self._flatten_base_subclass(v) + elif isinstance(v, JSONObject): + result[k] = v.dict return result diff --git a/test/unit/objects/mapped_object_test.py b/test/unit/objects/mapped_object_test.py index 2d83008ae..ac2448a4a 100644 --- a/test/unit/objects/mapped_object_test.py +++ b/test/unit/objects/mapped_object_test.py @@ -1,6 +1,7 @@ +from dataclasses import dataclass from test.unit.base import ClientBaseCase -from linode_api4.objects import Base, MappedObject, Property +from linode_api4.objects import Base, JSONObject, MappedObject, Property class MappedObjectCase(ClientBaseCase): @@ -20,7 +21,7 @@ def test_mapped_object_dict(self): mapped_obj = MappedObject(**test_dict) self.assertEqual(mapped_obj.dict, test_dict) - def test_mapped_object_dict(self): + def test_serialize_base_objects(self): test_property_name = "bar" test_property_value = "bar" @@ -42,3 +43,22 @@ class Foo(Base): mapped_obj = MappedObject(foo=foo) self.assertEqual(mapped_obj.dict, expected_dict) + + def test_serialize_json_objects(self): + test_property_name = "bar" + test_property_value = "bar" + + @dataclass + class Foo(JSONObject): + bar: str = "" + + foo = Foo.from_json({test_property_name: test_property_value}) + + expected_dict = { + "foo": { + test_property_name: test_property_value, + } + } + + mapped_obj = MappedObject(foo=foo) + self.assertEqual(mapped_obj.dict, expected_dict) From a931a60ce7c37a9ab1478e1e50c765d44e6ac81f Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 24 Apr 2024 11:48:31 -0700 Subject: [PATCH 193/379] test: refactor integration test directory structure and improve test command usage (#397) * change directory structure and makefile usage * remove unnecessary init files * remove unnecessary init files --- Makefile | 15 +++++++++------ README.rst | 9 +++------ test/integration/conftest.py | 5 ++++- .../models/{ => account}/test_account.py | 0 .../models/{ => database}/test_database.py | 0 .../models/{ => domain}/test_domain.py | 0 .../models/{ => firewall}/test_firewall.py | 0 test/integration/models/{ => image}/test_image.py | 0 .../models/{ => linode}/test_linode.py | 0 test/integration/models/{ => lke}/test_lke.py | 0 .../models/{ => longview}/test_longview.py | 0 .../models/{ => networking}/test_networking.py | 0 .../{ => nodebalancer}/test_nodebalancer.py | 0 test/integration/models/{ => tag}/test_tag.py | 0 .../models/{ => volume}/test_volume.py | 0 test/integration/models/{ => vpc}/test_vpc.py | 0 16 files changed, 16 insertions(+), 13 deletions(-) rename test/integration/models/{ => account}/test_account.py (100%) rename test/integration/models/{ => database}/test_database.py (100%) rename test/integration/models/{ => domain}/test_domain.py (100%) rename test/integration/models/{ => firewall}/test_firewall.py (100%) rename test/integration/models/{ => image}/test_image.py (100%) rename test/integration/models/{ => linode}/test_linode.py (100%) rename test/integration/models/{ => lke}/test_lke.py (100%) rename test/integration/models/{ => longview}/test_longview.py (100%) rename test/integration/models/{ => networking}/test_networking.py (100%) rename test/integration/models/{ => nodebalancer}/test_nodebalancer.py (100%) rename test/integration/models/{ => tag}/test_tag.py (100%) rename test/integration/models/{ => volume}/test_volume.py (100%) rename test/integration/models/{ => vpc}/test_vpc.py (100%) diff --git a/Makefile b/Makefile index 32d31cf20..e37fec3bf 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,22 @@ PYTHON ?= python3 -INTEGRATION_TEST_PATH := TEST_CASE_COMMAND := -MODEL_COMMAND := +TEST_SUITE := LINODE_SDK_VERSION ?= "0.0.0.dev" VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n VERSION_FILE := ./linode_api4/version.py ifdef TEST_CASE -TEST_CASE_COMMAND = -k $(TEST_CASE) + TEST_CASE_COMMAND = -k $(TEST_CASE) endif -ifdef TEST_MODEL -MODEL_COMMAND = models/$(TEST_MODEL) +ifdef TEST_SUITE + ifneq ($(TEST_SUITE),linode_client) + TEST_COMMAND = models/$(TEST_SUITE) + else + TEST_COMMAND = linode_client + endif endif .PHONY: clean @@ -67,7 +70,7 @@ lint: build .PHONY: testint testint: - $(PYTHON) -m pytest test/integration/${INTEGRATION_TEST_PATH}${MODEL_COMMAND} ${TEST_CASE_COMMAND} + $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} .PHONY: testunit testunit: diff --git a/README.rst b/README.rst index 7030f59df..bbbfeb31a 100644 --- a/README.rst +++ b/README.rst @@ -150,13 +150,10 @@ Run the tests locally using the make command. Run the entire test suite using co make testint -To run a specific package, use environment variable `INTEGRATION_TEST_PATH` with `testint` command:: +To run a specific package/suite, use the environment variable `TEST_SUITE` using directory names in `integration/...` folder :: - make INTEGRATION_TEST_PATH="linode_client" testint - -To run a specific model test suite, set the environment variable `TEST_MODEL` using file name in `integration/models`:: - - make TEST_MODEL="test_account.py" testint + make TEST_SUITE="account" testint // Runs tests in `integration/models/account` directory + make TEST_SUITE="linode_client" testint // Runs tests in `integration/linode_client` directory Lastly to run a specific test case use environment variable `TEST_CASE` with `testint` command:: diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3ba7d2b40..99670c56c 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -252,8 +252,11 @@ def test_firewall(test_linode_client): "inbound_policy": "ACCEPT", } + timestamp = str(time.time_ns()) + label = "firewall_" + timestamp + firewall = client.networking.firewall_create( - "test-firewall", rules=rules, status="enabled" + label=label, rules=rules, status="enabled" ) yield firewall diff --git a/test/integration/models/test_account.py b/test/integration/models/account/test_account.py similarity index 100% rename from test/integration/models/test_account.py rename to test/integration/models/account/test_account.py diff --git a/test/integration/models/test_database.py b/test/integration/models/database/test_database.py similarity index 100% rename from test/integration/models/test_database.py rename to test/integration/models/database/test_database.py diff --git a/test/integration/models/test_domain.py b/test/integration/models/domain/test_domain.py similarity index 100% rename from test/integration/models/test_domain.py rename to test/integration/models/domain/test_domain.py diff --git a/test/integration/models/test_firewall.py b/test/integration/models/firewall/test_firewall.py similarity index 100% rename from test/integration/models/test_firewall.py rename to test/integration/models/firewall/test_firewall.py diff --git a/test/integration/models/test_image.py b/test/integration/models/image/test_image.py similarity index 100% rename from test/integration/models/test_image.py rename to test/integration/models/image/test_image.py diff --git a/test/integration/models/test_linode.py b/test/integration/models/linode/test_linode.py similarity index 100% rename from test/integration/models/test_linode.py rename to test/integration/models/linode/test_linode.py diff --git a/test/integration/models/test_lke.py b/test/integration/models/lke/test_lke.py similarity index 100% rename from test/integration/models/test_lke.py rename to test/integration/models/lke/test_lke.py diff --git a/test/integration/models/test_longview.py b/test/integration/models/longview/test_longview.py similarity index 100% rename from test/integration/models/test_longview.py rename to test/integration/models/longview/test_longview.py diff --git a/test/integration/models/test_networking.py b/test/integration/models/networking/test_networking.py similarity index 100% rename from test/integration/models/test_networking.py rename to test/integration/models/networking/test_networking.py diff --git a/test/integration/models/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py similarity index 100% rename from test/integration/models/test_nodebalancer.py rename to test/integration/models/nodebalancer/test_nodebalancer.py diff --git a/test/integration/models/test_tag.py b/test/integration/models/tag/test_tag.py similarity index 100% rename from test/integration/models/test_tag.py rename to test/integration/models/tag/test_tag.py diff --git a/test/integration/models/test_volume.py b/test/integration/models/volume/test_volume.py similarity index 100% rename from test/integration/models/test_volume.py rename to test/integration/models/volume/test_volume.py diff --git a/test/integration/models/test_vpc.py b/test/integration/models/vpc/test_vpc.py similarity index 100% rename from test/integration/models/test_vpc.py rename to test/integration/models/vpc/test_vpc.py From ab50d06f24f1cb611561494a41bfa61b0775e9d6 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Fri, 26 Apr 2024 08:36:54 -0700 Subject: [PATCH 194/379] test: Add new tests for Oauth and Login Client (#403) * add tests for oauth and login client * remove duplicate lines --- .../login_client/test_login_client.py | 102 ++++++++++++++++++ .../models/account/test_account.py | 18 +--- 2 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 test/integration/login_client/test_login_client.py diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py new file mode 100644 index 000000000..0a24a4433 --- /dev/null +++ b/test/integration/login_client/test_login_client.py @@ -0,0 +1,102 @@ +import pytest + +from linode_api4 import OAuthScopes +from linode_api4.login_client import LinodeLoginClient +from linode_api4.objects import OAuthClient + + +@pytest.fixture +def linode_login_client(test_oauth_client): + client_id = test_oauth_client.id + client_secret = test_oauth_client.secret + + login_client = LinodeLoginClient(client_id, client_secret) + + yield login_client + + +@pytest.fixture +def test_oauth_client_two(test_linode_client): + client = test_linode_client + oauth_client = client.account.oauth_client_create( + "test-oauth-client-two", "https://localhost/oauth/callback" + ) + + yield oauth_client + + oauth_client.delete() + + +def test_get_oathclient(test_linode_client, test_oauth_client): + client = test_linode_client + + oauth_client = client.load(OAuthClient, test_oauth_client.id) + + assert "test-oauth-client" == oauth_client.label + assert "https://localhost/oauth/callback" == oauth_client.redirect_uri + + +def test_get_oauth_clients( + test_linode_client, test_oauth_client, test_oauth_client_two +): + oauth_clients = test_linode_client.account.oauth_clients() + + id_list = [o_cli.id for o_cli in oauth_clients] + + assert str(test_oauth_client.id) in id_list + assert str(test_oauth_client_two.id) in id_list + + +def test_get_oauth_clients_dont_reveal_secret(test_linode_client): + oauth_client_secret = test_linode_client.account.oauth_clients()[0].secret + + assert oauth_client_secret == "" + + +def test_edit_oauth_client_details(test_linode_client, test_oauth_client_two): + test_oauth_client_two.redirect_uri = ( + "https://localhost/oauth/callback_changed" + ) + test_oauth_client_two.label = "new_oauthclient_label" + test_oauth_client_two.save() + + oau_client = test_linode_client.load(OAuthClient, test_oauth_client_two.id) + + assert oau_client.redirect_uri == "https://localhost/oauth/callback_changed" + assert oau_client.label == "new_oauthclient_label" + + +def test_oauth_client_reset_secrets(test_oauth_client_two): + old_secret = test_oauth_client_two.secret + + new_secret = test_oauth_client_two.reset_secret() + + assert old_secret != new_secret + + +def test_linode_login_client_generate_default_login_url(linode_login_client): + client_id = linode_login_client.client_id + url = linode_login_client.generate_login_url() + + assert ( + "https://login.linode.com/oauth/authorize?client_id=" + + str(client_id) + + "&response_type=code" + == url + ) + + +def test_linode_login_client_generate_login_url_with_scope(linode_login_client): + url = linode_login_client.generate_login_url( + scopes=OAuthScopes.Linodes.read_write + ) + + assert "scopes=linodes%3Aread_write" in url + + +def test_linode_login_client_expire_token( + linode_login_client, test_oauth_client +): + result = linode_login_client.expire_token(token=test_oauth_client.secret) + + assert result is True diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 3d5fa2d97..c2d2ffb8a 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -3,14 +3,7 @@ import pytest -from linode_api4.objects import ( - Account, - AccountSettings, - Event, - Login, - OAuthClient, - User, -) +from linode_api4.objects import Account, AccountSettings, Event, Login, User @pytest.mark.smoke @@ -80,15 +73,6 @@ def test_latest_get_event(test_linode_client): assert label in latest_event["entity"]["label"] -def test_get_oathclient(test_linode_client, test_oauth_client): - client = test_linode_client - - oauth_client = client.load(OAuthClient, test_oauth_client.id) - - assert "test-oauth-client" == oauth_client.label - assert "https://localhost/oauth/callback" == oauth_client.redirect_uri - - def test_get_user(test_linode_client): client = test_linode_client From 098d050666a1cc0130f1e5c61dc7929c70f2e9cb Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:27:01 -0400 Subject: [PATCH 195/379] update pyenv for local environments (#404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Updates `pyenv` to point to local environment setup by devenv. --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index d20cc2bf0..6905745d0 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.8.10 +linode_api4-python From 0c039d863fff039feafb2fa5b5e43c747d0ee7ee Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 1 May 2024 12:13:09 -0400 Subject: [PATCH 196/379] new: Add support for Placement Groups (#396) --- docs/linode_api4/linode_client.rst | 9 ++ docs/linode_api4/objects/models.rst | 9 ++ linode_api4/groups/__init__.py | 1 + linode_api4/groups/linode.py | 10 +- linode_api4/groups/placement.py | 68 +++++++++++++ linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/linode.py | 111 +++++++++++++++++++- linode_api4/objects/placement.py | 99 ++++++++++++++++++ linode_api4/objects/region.py | 14 +++ linode_api4/objects/serializable.py | 20 ++++ test/fixtures/linode_instances.json | 11 +- test/fixtures/placement_groups.json | 21 ++++ test/fixtures/placement_groups_123.json | 14 +++ test/fixtures/regions.json | 60 +++++++++-- test/integration/conftest.py | 38 ++++++- test/integration/models/test_placement.py | 46 +++++++++ test/unit/objects/linode_test.py | 44 +++++++- test/unit/objects/placement_test.py | 117 ++++++++++++++++++++++ test/unit/objects/region_test.py | 6 ++ 20 files changed, 687 insertions(+), 16 deletions(-) create mode 100644 linode_api4/groups/placement.py create mode 100644 linode_api4/objects/placement.py create mode 100644 test/fixtures/placement_groups.json create mode 100644 test/fixtures/placement_groups_123.json create mode 100644 test/integration/models/test_placement.py create mode 100644 test/unit/objects/placement_test.py diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index 58c7025b8..9e8d135c6 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -155,6 +155,15 @@ with buckets and objects, use the s3 API directly with a library like `boto3`_. .. _boto3: https://github.com/boto/boto3 +PlacementAPIGroup +^^^^^^^^^^^^ + +Includes methods related to VM placement. + +.. autoclass:: linode_api4.linode_client.PlacementAPIGroup + :members: + :special-members: + PollingGroup ^^^^^^^^^^^^ diff --git a/docs/linode_api4/objects/models.rst b/docs/linode_api4/objects/models.rst index 6805ad889..8cef969c6 100644 --- a/docs/linode_api4/objects/models.rst +++ b/docs/linode_api4/objects/models.rst @@ -104,6 +104,15 @@ Object Storage Models :undoc-members: :inherited-members: +Placement Models +-------------- + +.. automodule:: linode_api4.objects.placement + :members: + :exclude-members: api_endpoint, properties, derived_url_path, id_attribute, parent_id_name + :undoc-members: + :inherited-members: + Profile Models -------------- diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 25c4858eb..db08d8939 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -12,6 +12,7 @@ from .networking import * from .nodebalancer import * from .object_storage import * +from .placement import * from .polling import * from .profile import * from .region import * diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 982eede81..cf6f618c7 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -2,7 +2,7 @@ import os from collections.abc import Iterable -from linode_api4 import Profile +from linode_api4 import InstancePlacementGroupAssignment, Profile from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -20,6 +20,7 @@ Type, ) from linode_api4.objects.filtering import Filter +from linode_api4.objects.linode import _expand_placement_group_assignment from linode_api4.paginated_list import PaginatedList @@ -269,6 +270,8 @@ def instance_create( :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. :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] :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -315,6 +318,11 @@ def instance_create( for i in interfaces ] + if "placement_group" in kwargs: + kwargs["placement_group"] = _expand_placement_group_assignment( + kwargs.get("placement_group") + ) + params = { "type": ltype.id if issubclass(type(ltype), Base) else ltype, "region": region.id if issubclass(type(region), Base) else region, diff --git a/linode_api4/groups/placement.py b/linode_api4/groups/placement.py new file mode 100644 index 000000000..20bf0d804 --- /dev/null +++ b/linode_api4/groups/placement.py @@ -0,0 +1,68 @@ +from typing import Union + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects.placement import PlacementGroup +from linode_api4.objects.region import Region + + +class PlacementAPIGroup(Group): + def groups(self, *filters): + """ + Returns a list of Placement Groups on your account. You may filter + this query to return only Placement Groups that match specific criteria:: + + groups = client.placement.groups(PlacementGroup.label == "test") + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Placement Groups that matched the query. + :rtype: PaginatedList of PlacementGroup + """ + return self.client._get_and_filter(PlacementGroup, *filters) + + def group_create( + self, + label: str, + region: Union[Region, str], + affinity_type: str, + is_strict: bool = False, + **kwargs, + ) -> PlacementGroup: + """ + Create a placement group with the specified parameters. + + :param label: The label for the placement group. + :type label: str + :param region: The region where the placement group will be created. Can be either a Region object or a string representing the region ID. + :type region: Union[Region, str] + :param affinity_type: The affinity type of the placement group. + :type affinity_type: PlacementGroupAffinityType + :param is_strict: Whether the placement group is strict (defaults to False). + :type is_strict: bool + + :returns: The new Placement Group. + :rtype: PlacementGroup + """ + params = { + "label": label, + "region": region.id if isinstance(region, Region) else region, + "affinity_type": affinity_type, + "is_strict": is_strict, + } + + params.update(kwargs) + + result = self.client.post("/placement/groups", data=params) + + if not "id" in result: + raise UnexpectedResponseError( + "Unexpected response when creating Placement Group", json=result + ) + + d = PlacementGroup(self.client, result["id"], result) + return d diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index d55958884..a348dc407 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -34,6 +34,7 @@ from linode_api4.objects.filtering import Filter from .common import SSH_KEY_TYPES, load_and_validate_keys +from .groups.placement import PlacementAPIGroup from .paginated_list import PaginatedList from .util import drop_null_keys @@ -200,6 +201,9 @@ def __init__( #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. self.beta = BetaProgramGroup(self) + #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. + self.placement = PlacementAPIGroup(self) + @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index f10d4d04f..3ecce4584 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -20,3 +20,4 @@ from .database import * from .vpc import * from .beta import * +from .placement import * diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index f459f5918..d86ec1746 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -335,6 +335,18 @@ def subnet(self) -> VPCSubnet: return VPCSubnet(self._client, self.subnet_id, self.vpc_id) +@dataclass +class InstancePlacementGroupAssignment(JSONObject): + """ + Represents an assignment between an instance and a Placement Group. + This is intended to be used when creating, cloning, and migrating + instances. + """ + + id: int + compliant_only: bool = False + + @dataclass class ConfigInterface(JSONObject): """ @@ -870,6 +882,37 @@ def transfer(self): return self._transfer + @property + def placement_group(self) -> Optional["PlacementGroup"]: + """ + Returns the PlacementGroup object for the Instance. + + :returns: The Placement Group this instance is under. + :rtype: Optional[PlacementGroup] + """ + # Workaround to avoid circular import + from linode_api4.objects.placement import ( # pylint: disable=import-outside-toplevel + PlacementGroup, + ) + + if not hasattr(self, "_placement_group"): + # Refresh the instance if necessary + if not self._populated: + self._api_get() + + pg_data = self._raw_json.get("placement_group", None) + + if pg_data is None: + return None + + setattr( + self, + "_placement_group", + PlacementGroup(self._client, pg_data.get("id"), json=pg_data), + ) + + return self._placement_group + def _populate(self, json): if json is not None: # fixes ipv4 and ipv6 attribute of json to make base._populate work @@ -885,11 +928,16 @@ def invalidate(self): """Clear out cached properties""" if hasattr(self, "_avail_backups"): del self._avail_backups + if hasattr(self, "_ips"): del self._ips + if hasattr(self, "_transfer"): del self._transfer + if hasattr(self, "_placement_group"): + del self._placement_group + Base.invalidate(self) def boot(self, config=None): @@ -1471,6 +1519,9 @@ def initiate_migration( region=None, upgrade=None, migration_type: MigrationType = MigrationType.COLD, + placement_group: Union[ + InstancePlacementGroupAssignment, Dict[str, Any], int + ] = None, ): """ Initiates a pending migration that is already scheduled for this Linode @@ -1496,12 +1547,19 @@ def initiate_migration( :param migration_type: The type of migration that will be used for this Linode migration. Customers can only use this param when activating a support-created migration. Customers can choose between a cold and warm migration, cold is the default type. - :type: mirgation_type: str + :type: migration_type: str + + :param placement_group: Information about the placement group to create this instance under. + :type placement_group: Union[InstancePlacementGroupAssignment, Dict[str, Any], int] """ + params = { "region": region.id if issubclass(type(region), Base) else region, "upgrade": upgrade, "type": migration_type, + "placement_group": _expand_placement_group_assignment( + placement_group + ), } util.drop_null_keys(params) @@ -1583,6 +1641,12 @@ def clone( label=None, group=None, with_backups=None, + placement_group: Union[ + InstancePlacementGroupAssignment, + "PlacementGroup", + Dict[str, Any], + int, + ] = None, ): """ Clones this linode into a new linode or into a new linode in the given region @@ -1618,6 +1682,9 @@ def clone( enrolled in the Linode Backup service. This will incur an additional charge. :type: with_backups: bool + :param placement_group: Information about the placement group to create this instance under. + :type placement_group: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + :returns: The cloned Instance. :rtype: Instance """ @@ -1654,8 +1721,13 @@ def clone( "label": label, "group": group, "with_backups": with_backups, + "placement_group": _expand_placement_group_assignment( + placement_group + ), } + util.drop_null_keys(params) + result = self._client.post( "{}/clone".format(Instance.api_endpoint), model=self, data=params ) @@ -1790,3 +1862,40 @@ def _serialize(self): dct = Base._serialize(self) dct["images"] = [d.id for d in self.images] return dct + + +def _expand_placement_group_assignment( + pg: Union[ + InstancePlacementGroupAssignment, "PlacementGroup", Dict[str, Any], int + ] +) -> Optional[Dict[str, Any]]: + """ + Expands the placement group argument into a dict for use in an API request body. + + :param pg: The placement group argument to be expanded. + :type pg: Union[InstancePlacementGroupAssignment, PlacementGroup, Dict[str, Any], int] + + :returns: The expanded placement group. + :rtype: Optional[Dict[str, Any]] + """ + # Workaround to avoid circular import + from linode_api4.objects.placement import ( # pylint: disable=import-outside-toplevel + PlacementGroup, + ) + + if pg is None: + return None + + if isinstance(pg, dict): + return pg + + if isinstance(pg, InstancePlacementGroupAssignment): + return pg.dict + + if isinstance(pg, PlacementGroup): + return {"id": pg.id} + + if isinstance(pg, int): + return {"id": pg} + + raise TypeError(f"Invalid type for Placement Group: {type(pg)}") diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py new file mode 100644 index 000000000..876f69ca4 --- /dev/null +++ b/linode_api4/objects/placement.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass +from typing import List, Union + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.linode import Instance +from linode_api4.objects.region import Region +from linode_api4.objects.serializable import JSONObject, StrEnum + + +class PlacementGroupAffinityType(StrEnum): + """ + An enum class that represents the available affinity policies for Linodes + in a Placement Group. + """ + + anti_affinity_local = "anti_affinity:local" + + +@dataclass +class PlacementGroupMember(JSONObject): + """ + Represents a member of a placement group. + """ + + linode_id: int = 0 + is_compliant: bool = False + + +class PlacementGroup(Base): + """ + A VM Placement Group, defining the affinity policy for Linodes + created in a region. + + API Documentation: TODO + """ + + api_endpoint = "/placement/groups/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + "region": Property(slug_relationship=Region), + "affinity_type": Property(), + "is_compliant": Property(), + "is_strict": Property(), + "members": Property(json_object=PlacementGroupMember), + } + + def assign( + self, + linodes: List[Union[Instance, int]], + compliant_only: bool = False, + ): + """ + Assigns the specified Linodes to the Placement Group. + + :param linodes: A list of Linodes to assign to the Placement Group. + :type linodes: List[Union[Instance, int]] + :param compliant_only: TODO + :type compliant_only: bool + """ + params = { + "linodes": [ + v.id if isinstance(v, Instance) else v for v in linodes + ], + "compliant_only": compliant_only, + } + + result = self._client.post( + f"{PlacementGroup.api_endpoint}/assign", model=self, data=params + ) + + # The assign endpoint returns the updated PG, so we can use this + # as an opportunity to refresh the object + self._populate(result) + + def unassign( + self, + linodes: List[Union[Instance, int]], + ): + """ + Unassign the specified Linodes from the Placement Group. + + :param linodes: A list of Linodes to unassign from the Placement Group. + :type linodes: List[Union[Instance, int]] + """ + params = { + "linodes": [ + v.id if isinstance(v, Instance) else v for v in linodes + ], + } + + result = self._client.post( + f"{PlacementGroup.api_endpoint}/unassign", model=self, data=params + ) + + # The unassign endpoint returns the updated PG, so we can use this + # as an opportunity to refresh the object + self._populate(result) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index ab77074d0..68ca8fe5d 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -7,6 +7,17 @@ from linode_api4.objects.serializable import JSONFilterableMetaclass +@dataclass +class RegionPlacementGroupLimits(JSONObject): + """ + Represents the Placement Group limits for the current account + in a specific region. + """ + + maximum_pgs_per_customer: int = 0 + maximum_linodes_per_pg: int = 0 + + class Region(Base): """ A Region. Regions correspond to individual data centers, each located in a different geographical area. @@ -23,6 +34,9 @@ class Region(Base): "resolvers": Property(), "label": Property(), "site_type": Property(), + "placement_group_limits": Property( + json_object=RegionPlacementGroupLimits + ), } @property diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index e4199283b..6cf06e56f 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,6 @@ import inspect from dataclasses import asdict, dataclass +from enum import Enum from types import SimpleNamespace from typing import ( Any, @@ -141,3 +142,22 @@ def __delitem__(self, key): def __len__(self): return len(vars(self)) + + +class StrEnum(str, Enum): + """ + Used for enums that are of type string, which is necessary + for implicit JSON serialization. + + NOTE: Replace this with StrEnum once Python 3.10 has been EOL'd. + See: https://docs.python.org/3/library/enum.html#enum.StrEnum + """ + + def __new__(cls, *values): + value = str(*values) + member = str.__new__(cls, value) + member._value_ = value + return member + + def __str__(self): + return self._value_ diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 3d257938d..651fc56c1 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -40,7 +40,13 @@ "image": "linode/ubuntu17.04", "tags": ["something"], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", - "watchdog_enabled": true + "watchdog_enabled": true, + "placement_group": { + "id": 123, + "label": "test", + "affinity_type": "anti_affinity:local", + "is_strict": true + } }, { "group": "test", @@ -79,7 +85,8 @@ "image": "linode/debian9", "tags": [], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", - "watchdog_enabled": false + "watchdog_enabled": false, + "placement_group": null } ] } diff --git a/test/fixtures/placement_groups.json b/test/fixtures/placement_groups.json new file mode 100644 index 000000000..f518e838d --- /dev/null +++ b/test/fixtures/placement_groups.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": 123, + "label": "test", + "region": "eu-west", + "affinity_type": "anti_affinity:local", + "is_strict": true, + "is_compliant": true, + "members": [ + { + "linode_id": 123, + "is_compliant": true + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/placement_groups_123.json b/test/fixtures/placement_groups_123.json new file mode 100644 index 000000000..5262bebe0 --- /dev/null +++ b/test/fixtures/placement_groups_123.json @@ -0,0 +1,14 @@ +{ + "id": 123, + "label": "test", + "region": "eu-west", + "affinity_type": "anti_affinity:local", + "is_strict": true, + "is_compliant": true, + "members": [ + { + "linode_id": 123, + "is_compliant": true + } + ] +} \ No newline at end of file diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index 9200455d4..5fe55e200 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -14,7 +14,11 @@ "ipv6": "2400:8904::f03c:91ff:fea5:659,2400:8904::f03c:91ff:fea5:9282,2400:8904::f03c:91ff:fea5:b9b3,2400:8904::f03c:91ff:fea5:925a,2400:8904::f03c:91ff:fea5:22cb,2400:8904::f03c:91ff:fea5:227a,2400:8904::f03c:91ff:fea5:924c,2400:8904::f03c:91ff:fea5:f7e2,2400:8904::f03c:91ff:fea5:2205,2400:8904::f03c:91ff:fea5:9207" }, "label": "label1", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ca-central", @@ -30,7 +34,11 @@ "ipv6": "2600:3c04::f03c:91ff:fea9:f63,2600:3c04::f03c:91ff:fea9:f6d,2600:3c04::f03c:91ff:fea9:f80,2600:3c04::f03c:91ff:fea9:f0f,2600:3c04::f03c:91ff:fea9:f99,2600:3c04::f03c:91ff:fea9:fbd,2600:3c04::f03c:91ff:fea9:fdd,2600:3c04::f03c:91ff:fea9:fe2,2600:3c04::f03c:91ff:fea9:f68,2600:3c04::f03c:91ff:fea9:f4a" }, "label": "label2", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ap-southeast", @@ -62,7 +70,11 @@ "ipv6": "2600:3c00::2,2600:3c00::9,2600:3c00::7,2600:3c00::5,2600:3c00::3,2600:3c00::8,2600:3c00::6,2600:3c00::4,2600:3c00::c,2600:3c00::b" }, "label": "label4", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "us-west", @@ -78,7 +90,11 @@ "ipv6": "2600:3c01::2,2600:3c01::9,2600:3c01::5,2600:3c01::7,2600:3c01::3,2600:3c01::8,2600:3c01::4,2600:3c01::b,2600:3c01::c,2600:3c01::6" }, "label": "label5", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "us-southeast", @@ -94,7 +110,11 @@ "ipv6": "2600:3c02::3,2600:3c02::5,2600:3c02::4,2600:3c02::6,2600:3c02::c,2600:3c02::7,2600:3c02::2,2600:3c02::9,2600:3c02::8,2600:3c02::b" }, "label": "label6", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "us-east", @@ -111,7 +131,11 @@ "ipv6": "2600:3c03::7,2600:3c03::4,2600:3c03::9,2600:3c03::6,2600:3c03::3,2600:3c03::c,2600:3c03::5,2600:3c03::b,2600:3c03::2,2600:3c03::8" }, "label": "label7", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "eu-west", @@ -127,7 +151,11 @@ "ipv6": "2a01:7e00::9,2a01:7e00::3,2a01:7e00::c,2a01:7e00::5,2a01:7e00::6,2a01:7e00::8,2a01:7e00::b,2a01:7e00::4,2a01:7e00::7,2a01:7e00::2" }, "label": "label8", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ap-south", @@ -144,7 +172,11 @@ "ipv6": "2400:8901::5,2400:8901::4,2400:8901::b,2400:8901::3,2400:8901::9,2400:8901::2,2400:8901::8,2400:8901::7,2400:8901::c,2400:8901::6" }, "label": "label9", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "eu-central", @@ -161,7 +193,11 @@ "ipv6": "2a01:7e01::5,2a01:7e01::9,2a01:7e01::7,2a01:7e01::c,2a01:7e01::2,2a01:7e01::4,2a01:7e01::3,2a01:7e01::6,2a01:7e01::b,2a01:7e01::8" }, "label": "label10", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } }, { "id": "ap-northeast", @@ -177,7 +213,11 @@ "ipv6": "2400:8902::3,2400:8902::6,2400:8902::c,2400:8902::4,2400:8902::2,2400:8902::8,2400:8902::7,2400:8902::5,2400:8902::b,2400:8902::9" }, "label": "label11", - "site_type": "core" + "site_type": "core", + "placement_group_limits": { + "maximum_pgs_per_customer": 5, + "maximum_linodes_per_pg": 5 + } } ], "page": 1, diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3ba7d2b40..29f906cf3 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -5,7 +5,7 @@ import pytest -from linode_api4 import ApiError +from linode_api4 import ApiError, PlacementGroupAffinityType from linode_api4.linode_client import LinodeClient from linode_api4.objects import Region @@ -343,6 +343,42 @@ def create_multiple_vpcs(test_linode_client): vpc_2.delete() +@pytest.fixture(scope="session") +def create_placement_group(test_linode_client): + client = test_linode_client + + timestamp = str(int(time.time())) + + pg = client.placement.group_create( + "pythonsdk-" + timestamp, + "us-east", + PlacementGroupAffinityType.anti_affinity_local, + ) + yield pg + + pg.delete() + + +@pytest.fixture(scope="session") +def create_placement_group_with_linode( + test_linode_client, create_placement_group +): + client = test_linode_client + + inst = client.linode.instance_create( + "g6-nanode-1", + create_placement_group.region, + label=create_placement_group.label, + placement_group=create_placement_group, + ) + + create_placement_group.invalidate() + + yield create_placement_group, inst + + inst.delete() + + @pytest.mark.smoke def pytest_configure(config): config.addinivalue_line( diff --git a/test/integration/models/test_placement.py b/test/integration/models/test_placement.py new file mode 100644 index 000000000..7919ef432 --- /dev/null +++ b/test/integration/models/test_placement.py @@ -0,0 +1,46 @@ +from linode_api4 import PlacementGroup + + +def test_get_pg(test_linode_client, create_placement_group): + """ + Tests that a Placement Group can be loaded. + """ + pg = test_linode_client.load(PlacementGroup, create_placement_group.id) + assert pg.id == create_placement_group.id + + +def test_update_pg(test_linode_client, create_placement_group): + """ + Tests that a Placement Group can be updated successfully. + """ + pg = create_placement_group + new_label = create_placement_group.label + "-updated" + + pg.label = new_label + pg.save() + + pg = test_linode_client.load(PlacementGroup, pg.id) + + assert pg.label == new_label + + +def test_pg_assignment(test_linode_client, create_placement_group_with_linode): + """ + Tests that a Placement Group can be updated successfully. + """ + pg, inst = create_placement_group_with_linode + + assert pg.members[0].linode_id == inst.id + assert inst.placement_group.id == pg.id + + pg.unassign([inst]) + inst.invalidate() + + assert len(pg.members) == 0 + assert inst.placement_group is None + + pg.assign([inst]) + inst.invalidate() + + assert pg.members[0].linode_id == inst.id + assert inst.placement_group.id == pg.id diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index c68dcfef6..92a717f7b 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import NetworkInterface +from linode_api4 import InstancePlacementGroupAssignment, NetworkInterface from linode_api4.objects import ( Config, ConfigInterface, @@ -476,6 +476,48 @@ def test_build_instance_metadata(self): {"user_data": "cool"}, ) + def test_get_placement_group(self): + """ + Tests that you can get the placement group for a Linode + """ + linode = Instance(self.client, 123) + + pg = linode.placement_group + + assert pg.id == 123 + assert pg.label == "test" + assert pg.affinity_type == "anti_affinity:local" + + # Invalidate the instance and try again + # This makes sure the implicit refresh/cache logic works + # as expected + linode.invalidate() + + pg = linode.placement_group + + assert pg.id == 123 + assert pg.label == "test" + assert pg.affinity_type == "anti_affinity:local" + + def test_create_with_placement_group(self): + """ + Tests that you can create a Linode with a Placement Group + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "eu-west", + placement_group=InstancePlacementGroupAssignment( + id=123, + compliant_only=True, + ), + ) + + self.assertEqual( + m.call_data["placement_group"], {"id": 123, "compliant_only": True} + ) + class DiskTest(ClientBaseCase): """ diff --git a/test/unit/objects/placement_test.py b/test/unit/objects/placement_test.py new file mode 100644 index 000000000..f3809d898 --- /dev/null +++ b/test/unit/objects/placement_test.py @@ -0,0 +1,117 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + PlacementGroup, + PlacementGroupAffinityType, + PlacementGroupMember, +) + + +class PlacementTest(ClientBaseCase): + """ + Tests methods of the Placement Group + """ + + def test_get_placement_group(self): + """ + Tests that a Placement Group is loaded correctly by ID + """ + + pg = PlacementGroup(self.client, 123) + assert not pg._populated + + self.validate_pg_123(pg) + assert pg._populated + + def test_list_pgs(self): + """ + Tests that you can list PGs. + """ + + pgs = self.client.placement.groups() + + self.validate_pg_123(pgs[0]) + assert pgs[0]._populated + + def test_create_pg(self): + """ + Tests that you can create a Placement Group. + """ + + with self.mock_post("/placement/groups/123") as m: + pg = self.client.placement.group_create( + "test", + "eu-west", + PlacementGroupAffinityType.anti_affinity_local, + is_strict=True, + ) + + assert m.call_url == "/placement/groups" + + self.assertEqual( + m.call_data, + { + "label": "test", + "region": "eu-west", + "affinity_type": str( + PlacementGroupAffinityType.anti_affinity_local + ), + "is_strict": True, + }, + ) + + assert pg._populated + self.validate_pg_123(pg) + + def test_pg_assign(self): + """ + Tests that you can assign to a PG. + """ + + pg = PlacementGroup(self.client, 123) + assert not pg._populated + + with self.mock_post("/placement/groups/123") as m: + pg.assign([123], compliant_only=True) + + assert m.call_url == "/placement/groups/123/assign" + + # Ensure the PG state was populated + assert pg._populated + + self.assertEqual( + m.call_data, + {"linodes": [123], "compliant_only": True}, + ) + + def test_pg_unassign(self): + """ + Tests that you can unassign from a PG. + """ + + pg = PlacementGroup(self.client, 123) + assert not pg._populated + + with self.mock_post("/placement/groups/123") as m: + pg.unassign([123]) + + assert m.call_url == "/placement/groups/123/unassign" + + # Ensure the PG state was populated + assert pg._populated + + self.assertEqual( + m.call_data, + {"linodes": [123]}, + ) + + def validate_pg_123(self, pg: PlacementGroup): + assert pg.id == 123 + assert pg.label == "test" + assert pg.region.id == "eu-west" + assert pg.affinity_type == "anti_affinity:local" + assert pg.is_strict + assert pg.is_compliant + assert pg.members[0] == PlacementGroupMember( + linode_id=123, is_compliant=True + ) diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 77b7ee2a9..f2f2d6013 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -23,6 +23,12 @@ def test_get_region(self): self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) self.assertEqual(region.site_type, "core") + self.assertEqual( + region.placement_group_limits.maximum_pgs_per_customer, 5 + ) + self.assertEqual( + region.placement_group_limits.maximum_linodes_per_pg, 5 + ) def test_list_availability(self): """ From e4ffa17a97c85d8c3f0a32082132aac79e47cd40 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 8 May 2024 12:31:09 -0700 Subject: [PATCH 197/379] CI: replace test execution handler with conditional (#405) * replace execution handler with conditional * adding if statement --- .github/workflows/e2e-test.yml | 24 +++++------------------- Makefile | 3 ++- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 166b70e75..0d22f5dd9 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -36,14 +36,12 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - status=0 - if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then - echo "EXIT_STATUS=1" >> $GITHUB_ENV - fi + make testint TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Add additional information to XML report + - name: Upload test results + if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python tod_scripts/add_to_xml_test_report.py \ @@ -51,20 +49,8 @@ jobs: --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" - - - name: Upload test results - run: | - report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python3 tod_scripts/test_report_upload_script.py "${report_filename}" + sync + python3 tod_scripts/test_report_upload_script.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - - - name: Test Execution Status Handler - run: | - if [[ "$EXIT_STATUS" != 0 ]]; then - echo "Test execution contains failure(s)" - exit $EXIT_STATUS - else - echo "Tests passed!" - fi \ No newline at end of file diff --git a/Makefile b/Makefile index e37fec3bf..e0d3da25f 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ PYTHON ?= python3 TEST_CASE_COMMAND := TEST_SUITE := +TEST_ARGS := LINODE_SDK_VERSION ?= "0.0.0.dev" VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n @@ -70,7 +71,7 @@ lint: build .PHONY: testint testint: - $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} + $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} ${TEST_ARGS} .PHONY: testunit testunit: From 5f1c1df6c10965a16fdea2cb40e295e73b24ca3b Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 23 May 2024 15:00:13 -0400 Subject: [PATCH 198/379] new: Add support for LKE Control Plane ACLs (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description This pull request adds support for configuring and viewing the ACL configuration for an LKE cluster's control plane. **NOTE: This PR does NOT include the PUT LKE cluster changes because the acl configuration is not returned in the GET LKE cluster response.** #### New Endpoint Methods * `control_plane_acl` - GET `/lke/clusters/{cluster_id}/control_plane_acl` * `control_plane_acl_update(...)` - PUT `/lke/clusters/{cluster_id}/control_plane_acl` * `control_plane_acl_delete()` - POST `/lke/clusters/{cluster_id}/control_plane_acl` #### Updated Endpoint Methods * `LKEGroup.cluster_create(...)` - Add control_plane field to method arguments #### Misc Changes * Added data classes for LKE cluster control plane and all substructures * Added logic to JSONObject to remove `Optional` values from the generated dict if None * Added a new `always_include` class var used to designate optional values that should always be included in the generated Dict * Updated test fixture framework to support underscores in path ## ✔️ How to Test The following test steps assume you have pulled down this PR locally and run `make install`. ### Unit Testing ``` make testunit ``` ### Integration Testing ``` make TEST_COMMAND=models/lke/test_lke.py testint ``` ### Manual Testing In a Python SDK sandbox environment (e.g. dx-devenv), run the following: ```python import os from linode_api4 import ( LinodeClient, LKEClusterControlPlaneOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneACLAddressesOptions, ) client = LinodeClient(token=os.getenv("LINODE_TOKEN")) cluster = client.lke.cluster_create( "us-mia", "test-cluster", client.lke.node_pool("g6-standard-1", 1), "1.29", control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, addresses=LKEClusterControlPlaneACLAddressesOptions( ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] ), ) ), ) print("Original ACL:", cluster.control_plane_acl) cluster.control_plane_acl_update( LKEClusterControlPlaneACLOptions( addresses=LKEClusterControlPlaneACLAddressesOptions(ipv4=["10.0.0.2/32"]) ) ) print("Updated ACL:", cluster.control_plane_acl) cluster.control_plane_acl_delete() print("Deleted ACL:", cluster.control_plane_acl) ``` 2. Ensure the script runs successfully and the output matches the following: ``` Original ACL: LKEClusterControlPlaneACL(enabled=True, addresses=LKEClusterControlPlaneACLAddresses(ipv4=['10.0.0.1/32'], ipv6=['1234::5678/128'])) Updated ACL: LKEClusterControlPlaneACL(enabled=True, addresses=LKEClusterControlPlaneACLAddresses(ipv4=['10.0.0.2/32'], ipv6=None)) Deleted ACL: LKEClusterControlPlaneACL(enabled=False, addresses=None) ``` --- linode_api4/groups/lke.py | 32 +++- linode_api4/objects/lke.py | 138 ++++++++++++++++++ linode_api4/objects/serializable.py | 90 +++++++++++- linode_api4/objects/vpc.py | 4 +- ...ke_clusters_18881_control__plane__acl.json | 13 ++ test/integration/models/lke/test_lke.py | 56 +++++++ test/unit/fixtures.py | 9 +- test/unit/objects/lke_test.py | 114 ++++++++++++++- test/unit/objects/serializable_test.py | 28 ++++ 9 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 test/fixtures/lke_clusters_18881_control__plane__acl.json create mode 100644 test/unit/objects/serializable_test.py diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index 60ec480b5..0e2785939 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -1,6 +1,15 @@ +from typing import Any, Dict, Union + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, KubeVersion, LKECluster +from linode_api4.objects import ( + Base, + JSONObject, + KubeVersion, + LKECluster, + LKEClusterControlPlaneOptions, + drop_null_keys, +) class LKEGroup(Group): @@ -47,7 +56,17 @@ def clusters(self, *filters): """ return self.client._get_and_filter(LKECluster, *filters) - def cluster_create(self, region, label, node_pools, kube_version, **kwargs): + def cluster_create( + self, + region, + label, + node_pools, + kube_version, + control_plane: Union[ + LKEClusterControlPlaneOptions, Dict[str, Any] + ] = None, + **kwargs, + ): """ Creates an :any:`LKECluster` on this account in the given region, with the given label, and with node pools as described. For example:: @@ -80,6 +99,8 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): formatted dicts. :param kube_version: The version of Kubernetes to use :type kube_version: KubeVersion or str + :param control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest + :type control_plane: The control plane configuration of this LKE cluster. :param kwargs: Any other arguments to pass along to the API. See the API docs for possible values. @@ -112,10 +133,15 @@ def cluster_create(self, region, label, node_pools, kube_version, **kwargs): if issubclass(type(kube_version), Base) else kube_version ), + "control_plane": ( + control_plane.dict + if issubclass(type(control_plane), JSONObject) + else control_plane + ), } params.update(kwargs) - result = self.client.post("/lke/clusters", data=params) + result = self.client.post("/lke/clusters", data=drop_null_keys(params)) if "id" not in result: raise UnexpectedResponseError( diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index f1769685a..55dd0372e 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union from urllib import parse from linode_api4.errors import UnexpectedResponseError @@ -5,6 +7,7 @@ Base, DerivedBase, Instance, + JSONObject, MappedObject, Property, Region, @@ -26,6 +29,61 @@ class KubeVersion(Base): } +@dataclass +class LKEClusterControlPlaneACLAddressesOptions(JSONObject): + """ + LKEClusterControlPlaneACLAddressesOptions are options used to configure + IP ranges that are explicitly allowed to access an LKE cluster's control plane. + """ + + ipv4: Optional[List[str]] = None + ipv6: Optional[List[str]] = None + + +@dataclass +class LKEClusterControlPlaneACLOptions(JSONObject): + """ + LKEClusterControlPlaneACLOptions is used to set + the ACL configuration of an LKE cluster's control plane. + """ + + enabled: Optional[bool] = None + addresses: Optional[LKEClusterControlPlaneACLAddressesOptions] = None + + +@dataclass +class LKEClusterControlPlaneOptions(JSONObject): + """ + LKEClusterControlPlaneOptions is used to configure + the control plane of an LKE cluster during its creation. + """ + + high_availability: Optional[bool] = None + acl: Optional[LKEClusterControlPlaneACLOptions] = None + + +@dataclass +class LKEClusterControlPlaneACLAddresses(JSONObject): + """ + LKEClusterControlPlaneACLAddresses describes IP ranges that are explicitly allowed + to access an LKE cluster's control plane. + """ + + ipv4: List[str] = None + ipv6: List[str] = None + + +@dataclass +class LKEClusterControlPlaneACL(JSONObject): + """ + LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's + control plane. + """ + + enabled: bool = False + addresses: LKEClusterControlPlaneACLAddresses = None + + class LKENodePoolNode: """ AN LKE Node Pool Node is a helper class that is used to populate the "nodes" @@ -129,6 +187,21 @@ class LKECluster(Base): "control_plane": Property(mutable=True), } + def invalidate(self): + """ + Extends the default invalidation logic to drop cached properties. + """ + if hasattr(self, "_api_endpoints"): + del self._api_endpoints + + if hasattr(self, "_kubeconfig"): + del self._kubeconfig + + if hasattr(self, "_control_plane_acl"): + del self._control_plane_acl + + Base.invalidate(self) + @property def api_endpoints(self): """ @@ -186,6 +259,26 @@ def kubeconfig(self): return self._kubeconfig + @property + def control_plane_acl(self) -> LKEClusterControlPlaneACL: + """ + Gets the ACL configuration of this cluster's control plane. + + API Documentation: TODO + + :returns: The cluster's control plane ACL configuration. + :rtype: LKEClusterControlPlaneACL + """ + + if not hasattr(self, "_control_plane_acl"): + result = self._client.get( + f"{LKECluster.api_endpoint}/control_plane_acl", model=self + ) + + self._control_plane_acl = result.get("acl") + + return LKEClusterControlPlaneACL.from_json(self._control_plane_acl) + def node_pool_create(self, node_type, node_count, **kwargs): """ Creates a new :any:`LKENodePool` for this cluster. @@ -335,3 +428,48 @@ def service_token_delete(self): self._client.delete( "{}/servicetoken".format(LKECluster.api_endpoint), model=self ) + + def control_plane_acl_update( + self, acl: Union[LKEClusterControlPlaneACLOptions, Dict[str, Any]] + ) -> LKEClusterControlPlaneACL: + """ + Updates the ACL configuration for this cluster's control plane. + + API Documentation: TODO + + :param acl: The ACL configuration to apply to this cluster. + :type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any] + + :returns: The updated control plane ACL configuration. + :rtype: LKEClusterControlPlaneACL + """ + if isinstance(acl, LKEClusterControlPlaneACLOptions): + acl = acl.dict + + result = self._client.put( + f"{LKECluster.api_endpoint}/control_plane_acl", + model=self, + data={"acl": acl}, + ) + + acl = result.get("acl") + + self._control_plane_acl = result.get("acl") + + return LKEClusterControlPlaneACL.from_json(acl) + + def control_plane_acl_delete(self): + """ + Deletes the ACL configuration for this cluster's control plane. + This has the same effect as calling control_plane_acl_update with the `enabled` field + set to False. Access controls are disabled and all rules are deleted. + + API Documentation: TODO + """ + self._client.delete( + f"{LKECluster.api_endpoint}/control_plane_acl", model=self + ) + + # Invalidate the cache so it is automatically refreshed on next access + if hasattr(self, "_control_plane_acl"): + del self._control_plane_acl diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index e4199283b..15494cdce 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,11 +1,14 @@ import inspect -from dataclasses import asdict, dataclass +from dataclasses import dataclass from types import SimpleNamespace from typing import ( Any, ClassVar, Dict, + List, Optional, + Set, + Union, get_args, get_origin, get_type_hints, @@ -54,28 +57,57 @@ class JSONObject(metaclass=JSONFilterableMetaclass): ) """ + always_include: ClassVar[Set[str]] = {} + """ + A set of keys corresponding to fields that should always be + included in the generated output regardless of whether their values + are None. + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" ) # TODO: Implement __repr__ + @staticmethod + def _unwrap_type(field_type: type) -> type: + args = get_args(field_type) + origin_type = get_origin(field_type) + + # We don't want to try to unwrap Dict, List, Set, etc. values + if origin_type is not Union: + return field_type + + if len(args) == 0: + raise TypeError("Expected type to have arguments, got none") + + # Use the first type in the Union's args + return JSONObject._unwrap_type(args[0]) @staticmethod def _try_from_json(json_value: Any, field_type: type): """ Determines whether a JSON dict is an instance of a field type. """ + + field_type = JSONObject._unwrap_type(field_type) + if inspect.isclass(field_type) and issubclass(field_type, JSONObject): return field_type.from_json(json_value) + return json_value @classmethod - def _parse_attr_list(cls, json_value, field_type): + def _parse_attr_list(cls, json_value: Any, field_type: type): """ Attempts to parse a list attribute with a given value and field type. """ + # Edge case for optional list values + if json_value is None: + return None + type_hint_args = get_args(field_type) if len(type_hint_args) < 1: @@ -86,11 +118,13 @@ def _parse_attr_list(cls, json_value, field_type): ] @classmethod - def _parse_attr(cls, json_value, field_type): + def _parse_attr(cls, json_value: Any, field_type: type): """ Attempts to parse an attribute with a given value and field type. """ + field_type = JSONObject._unwrap_type(field_type) + if list in (field_type, get_origin(field_type)): return cls._parse_attr_list(json_value, field_type) @@ -117,7 +151,55 @@ def _serialize(self) -> Dict[str, Any]: """ Serializes this object into a JSON dict. """ - return asdict(self) + cls = type(self) + type_hints = get_type_hints(cls) + + def attempt_serialize(value: Any) -> Any: + """ + Attempts to serialize the given value, else returns the value unchanged. + """ + if issubclass(type(value), JSONObject): + return value._serialize() + + return value + + def should_include(key: str, value: Any) -> bool: + """ + Returns whether the given key/value pair should be included in the resulting dict. + """ + + if key in cls.always_include: + return True + + hint = type_hints.get(key) + + # We want to exclude any Optional values that are None + # NOTE: We need to check for Union here because Optional is an alias of Union. + if ( + hint is None + or get_origin(hint) is not Union + or type(None) not in get_args(hint) + ): + return True + + return value is not None + + result = {} + + for k, v in vars(self).items(): + if not should_include(k, v): + continue + + if isinstance(v, List): + v = [attempt_serialize(j) for j in v] + elif isinstance(v, Dict): + v = {k: attempt_serialize(j) for k, j in v.items()} + else: + v = attempt_serialize(v) + + result[k] = v + + return result @property def dict(self) -> Dict[str, Any]: diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 3f80b0925..e44eebcdc 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -100,7 +100,7 @@ def subnet_create( return d @property - def ips(self, *filters) -> PaginatedList: + def ips(self) -> PaginatedList: """ Get all the IP addresses under this VPC. @@ -116,5 +116,5 @@ def ips(self, *filters) -> PaginatedList: ) return self._client._get_and_filter( - VPCIPAddress, *filters, endpoint="/vpcs/{}/ips".format(self.id) + VPCIPAddress, endpoint="/vpcs/{}/ips".format(self.id) ) diff --git a/test/fixtures/lke_clusters_18881_control__plane__acl.json b/test/fixtures/lke_clusters_18881_control__plane__acl.json new file mode 100644 index 000000000..f4da34393 --- /dev/null +++ b/test/fixtures/lke_clusters_18881_control__plane__acl.json @@ -0,0 +1,13 @@ +{ + "acl": { + "enabled": true, + "addresses": { + "ipv4": [ + "10.0.0.1/32" + ], + "ipv6": [ + "1234::5678" + ] + } + } +} \ No newline at end of file diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 1cff9ec44..c711aef92 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -7,6 +7,11 @@ import pytest +from linode_api4 import ( + LKEClusterControlPlaneACLAddressesOptions, + LKEClusterControlPlaneACLOptions, + LKEClusterControlPlaneOptions, +) from linode_api4.errors import ApiError from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode @@ -28,6 +33,34 @@ def lke_cluster(test_linode_client): cluster.delete() +@pytest.fixture(scope="session") +def lke_cluster_with_acl(test_linode_client): + node_type = test_linode_client.linode.types()[1] # g6-standard-1 + version = test_linode_client.lke.versions()[0] + region = test_linode_client.regions().first() + node_pools = test_linode_client.lke.node_pool(node_type, 1) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + node_pools, + version, + control_plane=LKEClusterControlPlaneOptions( + acl=LKEClusterControlPlaneACLOptions( + enabled=True, + addresses=LKEClusterControlPlaneACLAddressesOptions( + ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] + ), + ) + ), + ) + + yield cluster + + cluster.delete() + + def get_cluster_status(cluster: LKECluster, status: str): return cluster._raw_json["status"] == status @@ -147,3 +180,26 @@ def test_service_token_delete(lke_cluster): res = cluster.service_token_delete() assert res is None + + +def test_lke_cluster_acl(lke_cluster_with_acl): + cluster = lke_cluster_with_acl + + assert cluster.control_plane_acl.enabled + assert cluster.control_plane_acl.addresses.ipv4 == ["10.0.0.1/32"] + assert cluster.control_plane_acl.addresses.ipv6 == ["1234::5678/128"] + + acl = cluster.control_plane_acl_update( + LKEClusterControlPlaneACLOptions( + addresses=LKEClusterControlPlaneACLAddressesOptions( + ipv4=["10.0.0.2/32"] + ) + ) + ) + + assert acl == cluster.control_plane_acl + assert acl.addresses.ipv4 == ["10.0.0.2/32"] + + cluster.control_plane_acl_delete() + + assert not cluster.control_plane_acl.enabled diff --git a/test/unit/fixtures.py b/test/unit/fixtures.py index a4609b22c..52d41d84c 100644 --- a/test/unit/fixtures.py +++ b/test/unit/fixtures.py @@ -1,9 +1,14 @@ import json import os +import re import sys FIXTURES_DIR = sys.path[0] + "/test/fixtures" +# This regex is useful for finding individual underscore characters, +# which is necessary to allow us to use underscores in URL paths. +PATH_REPLACEMENT_REGEX = re.compile(r"(? Date: Tue, 28 May 2024 12:28:33 -0700 Subject: [PATCH 199/379] test: Fix failure in oauth test due to missing fixture initialization (#407) * add login_client in Makefile and add initial fixture to oauth test * lint --- Makefile | 6 +++++- test/integration/login_client/test_login_client.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index e0d3da25f..03a527169 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,11 @@ endif ifdef TEST_SUITE ifneq ($(TEST_SUITE),linode_client) - TEST_COMMAND = models/$(TEST_SUITE) + ifneq ($(TEST_SUITE),login_client) + TEST_COMMAND = models/$(TEST_SUITE) + else + TEST_COMMAND = login_client + endif else TEST_COMMAND = linode_client endif diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py index 0a24a4433..8631c2617 100644 --- a/test/integration/login_client/test_login_client.py +++ b/test/integration/login_client/test_login_client.py @@ -47,7 +47,9 @@ def test_get_oauth_clients( assert str(test_oauth_client_two.id) in id_list -def test_get_oauth_clients_dont_reveal_secret(test_linode_client): +def test_get_oauth_clients_dont_reveal_secret( + test_linode_client, test_oauth_client +): oauth_client_secret = test_linode_client.account.oauth_clients()[0].secret assert oauth_client_secret == "" From ddffa753b441677f8e54076b258164713b908440 Mon Sep 17 00:00:00 2001 From: Jacob Riddle Date: Mon, 3 Jun 2024 15:30:12 -0400 Subject: [PATCH 200/379] lint --- test/integration/models/account/test_account.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index d7ec41224..693d5bb82 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -3,7 +3,14 @@ import pytest -from linode_api4.objects import Account, AccountSettings, ChildAccount, Event, Login, User +from linode_api4.objects import ( + Account, + AccountSettings, + ChildAccount, + Event, + Login, + User, +) @pytest.mark.smoke From 90b43abd09208aaed8dbc8e8fd1d3bd94732efc5 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 4 Jun 2024 10:12:45 -0700 Subject: [PATCH 201/379] test: Address flaky tests in linode_client and lke (#412) * unskip lke test, update test_lke_node_recycle, filter swap disk for image create, skip parent child account test * skip pc account test * remove comment * update with cleaner syntax --- test/integration/linode_client/test_linode_client.py | 4 ++-- test/integration/models/account/test_account.py | 1 + test/integration/models/lke/test_lke.py | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index bc5d31292..3d26bdbb1 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -86,10 +86,10 @@ def test_image_create(setup_client_and_linode): label = get_test_label() description = "Test description" - disk_id = linode.disks.first().id + usable_disk = [v for v in linode.disks if v.filesystem != "swap"] image = client.image_create( - disk=disk_id, label=label, description=description + disk=usable_disk[0].id, label=label, description=description ) assert image.label == label diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 693d5bb82..337718709 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -95,6 +95,7 @@ def test_get_user(test_linode_client): def test_list_child_accounts(test_linode_client): + pytest.skip("Configure test account settings for Parent child") client = test_linode_client child_accounts = client.account.child_accounts() if len(child_accounts) > 0: diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index c711aef92..bbf87bedf 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -78,12 +78,11 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): - pytest.skip("TPT-2511") cluster = lke_cluster pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) - assert cluster.pools[0]._raw_json == pool + assert cluster.pools[0].id == pool.id def test_cluster_dashboard_url_view(lke_cluster): @@ -147,10 +146,11 @@ def test_lke_node_recycle(test_linode_client, lke_cluster): "ready", ) - node_pool = test_linode_client.load( - LKENodePool, cluster.pools[0].id, cluster.id - ) - node = node_pool.nodes[0] + # Reload cluster + cluster = test_linode_client.load(LKECluster, lke_cluster.id) + + node = cluster.pools[0].nodes[0] + assert node.status == "ready" From bee13ced1088a96c4debbc352710fba2ebd689bf Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:12:30 -0400 Subject: [PATCH 202/379] Turn on `remove-all-unused-imports` (#414) --- linode_api4/groups/account.py | 1 - linode_api4/groups/linode.py | 6 +----- linode_api4/groups/polling.py | 1 - linode_api4/linode_client.py | 3 --- linode_api4/objects/region.py | 2 -- linode_api4/objects/tag.py | 2 -- pyproject.toml | 2 +- test/integration/linode_client/test_linode_client.py | 2 +- test/integration/models/linode/test_linode.py | 3 +-- test/integration/models/lke/test_lke.py | 2 +- test/integration/models/tag/test_tag.py | 2 +- test/unit/objects/region_test.py | 2 +- test/unit/objects/vpc_test.py | 1 - 13 files changed, 7 insertions(+), 22 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 884503fe8..b45152908 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -19,7 +19,6 @@ ServiceTransfer, User, ) -from linode_api4.objects.profile import PersonalAccessToken class AccountGroup(Group): diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 982eede81..90c5d1e68 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -2,20 +2,16 @@ import os from collections.abc import Iterable -from linode_api4 import Profile -from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys +from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - AuthorizedApp, Base, ConfigInterface, Firewall, Image, Instance, Kernel, - PersonalAccessToken, - SSHKey, StackScript, Type, ) diff --git a/linode_api4/groups/polling.py b/linode_api4/groups/polling.py index 3eaa0edda..4141b78b9 100644 --- a/linode_api4/groups/polling.py +++ b/linode_api4/groups/polling.py @@ -1,7 +1,6 @@ import polling from linode_api4.groups import Group -from linode_api4.objects.account import Event from linode_api4.polling import EventPoller, TimeoutContext diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index d55958884..c13681085 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -31,11 +31,8 @@ VPCGroup, ) from linode_api4.objects import Image, and_ -from linode_api4.objects.filtering import Filter -from .common import SSH_KEY_TYPES, load_and_validate_keys from .paginated_list import PaginatedList -from .util import drop_null_keys package_version = version("linode_api4") diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index ab77074d0..e80aa2f2e 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -3,8 +3,6 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects.base import Base, JSONObject, Property -from linode_api4.objects.filtering import FilterableAttribute -from linode_api4.objects.serializable import JSONFilterableMetaclass class Region(Base): diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 5a604d445..856f0d751 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -1,7 +1,5 @@ -from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, - DerivedBase, Domain, Instance, NodeBalancer, diff --git a/pyproject.toml b/pyproject.toml index cec2adf11..4e2c60f00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,5 +86,5 @@ ignore-init-module-imports = true ignore-pass-after-docstring = true in-place = true recursive = true -remove-all-unused-imports = false +remove-all-unused-imports = true remove-duplicate-keys = false diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 3d26bdbb1..8e5c841b8 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -4,7 +4,7 @@ import pytest -from linode_api4 import ApiError, LinodeClient +from linode_api4 import ApiError from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index a749baad4..f903eb397 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -6,10 +6,9 @@ wait_for_condition, ) -import polling import pytest -from linode_api4 import LinodeClient, VPCIPAddress +from linode_api4 import VPCIPAddress from linode_api4.errors import ApiError from linode_api4.objects import ( Config, diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index bbf87bedf..4967c067f 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -13,7 +13,7 @@ LKEClusterControlPlaneOptions, ) from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode +from linode_api4.objects import LKECluster, LKENodePool @pytest.fixture(scope="session") diff --git a/test/integration/models/tag/test_tag.py b/test/integration/models/tag/test_tag.py index a9357a896..d2edf84c5 100644 --- a/test/integration/models/tag/test_tag.py +++ b/test/integration/models/tag/test_tag.py @@ -2,7 +2,7 @@ import pytest -from linode_api4.objects import Instance, Tag +from linode_api4.objects import Tag @pytest.fixture diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 77b7ee2a9..c5070c0a2 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,7 +1,7 @@ import json from test.unit.base import ClientBaseCase -from linode_api4.objects import Region, Type +from linode_api4.objects import Region from linode_api4.objects.region import RegionAvailabilityEntry diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 7e4963d33..4d80716d4 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -2,7 +2,6 @@ from test.unit.base import ClientBaseCase from linode_api4 import DATE_FORMAT, VPC, VPCSubnet -from linode_api4.objects import Volume class VPCTest(ClientBaseCase): From 7ccc16ce5fb396237821a5835fa0cc208cca92da Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 12 Jun 2024 09:03:33 -0700 Subject: [PATCH 203/379] test: Add Linode Cloud Firewall for integration tests (#408) * add cloud firewall to integration tests * pr comments * update fixture name * lint --- test/integration/conftest.py | 97 +++++++++++++++++-- .../linode_client/test_linode_client.py | 10 +- test/integration/models/linode/test_linode.py | 17 ++-- .../models/nodebalancer/test_nodebalancer.py | 9 +- test/integration/models/volume/test_volume.py | 8 +- 5 files changed, 121 insertions(+), 20 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 99670c56c..68caaa538 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,9 +1,11 @@ +import ipaddress import os import random import time from typing import Set import pytest +import requests from linode_api4 import ApiError from linode_api4.linode_client import LinodeClient @@ -50,16 +52,90 @@ def run_long_tests(): return os.environ.get(RUN_LONG_TESTS, None) +@pytest.fixture(autouse=True, scope="session") +def e2e_test_firewall(test_linode_client): + def is_valid_ipv4(address): + try: + ipaddress.IPv4Address(address) + return True + except ipaddress.AddressValueError: + return False + + def is_valid_ipv6(address): + try: + ipaddress.IPv6Address(address) + return True + except ipaddress.AddressValueError: + return False + + def get_public_ip(ip_version="ipv4"): + url = ( + f"https://api64.ipify.org?format=json" + if ip_version == "ipv6" + else f"https://api.ipify.org?format=json" + ) + response = requests.get(url) + return str(response.json()["ip"]) + + def create_inbound_rule(ipv4_address, ipv6_address): + rule = [ + { + "protocol": "TCP", + "ports": "22", + "addresses": {}, + "action": "ACCEPT", + } + ] + if is_valid_ipv4(ipv4_address): + rule[0]["addresses"]["ipv4"] = [f"{ipv4_address}/32"] + + if is_valid_ipv6(ipv6_address): + rule[0]["addresses"]["ipv6"] = [f"{ipv6_address}/128"] + + return rule + + # Fetch the public IP addresses + + ipv4_address = get_public_ip("ipv4") + ipv6_address = get_public_ip("ipv6") + + inbound_rule = create_inbound_rule(ipv4_address, ipv6_address) + + client = test_linode_client + + rules = { + "outbound": [], + "outbound_policy": "ACCEPT", + "inbound": inbound_rule, + "inbound_policy": "DROP", + } + + label = "cloud_firewall_" + str(int(time.time())) + + firewall = client.networking.firewall_create( + label=label, rules=rules, status="enabled" + ) + + yield firewall + + firewall.delete() + + @pytest.fixture(scope="session") -def create_linode(test_linode_client): +def create_linode(test_linode_client, e2e_test_firewall): client = test_linode_client + available_regions = client.regions() chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance @@ -68,15 +144,20 @@ def create_linode(test_linode_client): @pytest.fixture -def create_linode_for_pass_reset(test_linode_client): +def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall): client = test_linode_client + available_regions = client.regions() chosen_region = available_regions[4] timestamp = str(time.time_ns()) label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance, password @@ -303,7 +384,7 @@ def create_vpc_with_subnet(test_linode_client, create_vpc): @pytest.fixture(scope="session") def create_vpc_with_subnet_and_linode( - test_linode_client, create_vpc_with_subnet + test_linode_client, create_vpc_with_subnet, e2e_test_firewall ): vpc, subnet = create_vpc_with_subnet @@ -311,7 +392,11 @@ def create_vpc_with_subnet_and_linode( label = "TestSDK-" + timestamp instance, password = test_linode_client.linode.instance_create( - "g6-standard-1", vpc.region, image="linode/debian11", label=label + "g6-standard-1", + vpc.region, + image="linode/debian11", + label=label, + firewall=e2e_test_firewall, ) yield vpc, subnet, instance, password diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 8e5c841b8..c9ce35d6e 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -8,15 +8,19 @@ from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region -@pytest.fixture(scope="session", autouse=True) -def setup_client_and_linode(test_linode_client): +@pytest.fixture(scope="session") +def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] # us-ord (Chicago) label = get_test_label() linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield client, linode_instance diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index f903eb397..07ee54834 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -32,7 +32,7 @@ def linode_with_volume_firewall(test_linode_client): "outbound": [], "outbound_policy": "DROP", "inbound": [], - "inbound_policy": "ACCEPT", + "inbound_policy": "DROP", } linode_instance, password = client.linode.instance_create( @@ -68,7 +68,7 @@ def linode_with_volume_firewall(test_linode_client): @pytest.fixture(scope="session") -def linode_for_network_interface_tests(test_linode_client): +def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -76,7 +76,11 @@ def linode_for_network_interface_tests(test_linode_client): label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance @@ -85,7 +89,7 @@ def linode_for_network_interface_tests(test_linode_client): @pytest.fixture -def linode_for_disk_tests(test_linode_client): +def linode_for_disk_tests(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -96,6 +100,7 @@ def linode_for_disk_tests(test_linode_client): chosen_region, image="linode/alpine3.19", label=label + "_long_tests", + firewall=e2e_test_firewall, ) # Provisioning time @@ -118,7 +123,7 @@ def linode_for_disk_tests(test_linode_client): @pytest.fixture -def create_linode_for_long_running_tests(test_linode_client): +def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -129,6 +134,7 @@ def create_linode_for_long_running_tests(test_linode_client): chosen_region, image="linode/debian10", label=label + "_long_tests", + firewall=e2e_test_firewall, ) yield linode_instance @@ -411,7 +417,6 @@ def test_disk_resize_and_duplicate(test_linode_client, linode_for_disk_tests): time.sleep(40) wait_for_disk_status(dup_disk, 120) - assert dup_disk.linode_id == linode.id diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 6cec442b4..ab3095aaa 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -7,7 +7,7 @@ @pytest.fixture(scope="session") -def linode_with_private_ip(test_linode_client): +def linode_with_private_ip(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -19,6 +19,7 @@ def linode_with_private_ip(test_linode_client): image="linode/debian10", label=label, private_ip=True, + firewall=e2e_test_firewall, ) yield linode_instance @@ -27,13 +28,15 @@ def linode_with_private_ip(test_linode_client): @pytest.fixture(scope="session") -def create_nb_config(test_linode_client): +def create_nb_config(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] label = "nodebalancer_test" - nb = client.nodebalancer_create(region=chosen_region, label=label) + nb = client.nodebalancer_create( + region=chosen_region, label=label, firewall=e2e_test_firewall.id + ) config = nb.config_create() diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 1b351c14d..08e836a13 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -13,7 +13,7 @@ @pytest.fixture(scope="session") -def linode_for_volume(test_linode_client): +def linode_for_volume(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] @@ -21,7 +21,11 @@ def linode_for_volume(test_linode_client): label = "TestSDK-" + timestamp linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) yield linode_instance From 8a6e2d19e438b96728bd417b2fb9b6cde2bd922a Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 12 Jun 2024 13:43:16 -0400 Subject: [PATCH 204/379] Add note about limited LKE ACL availability (#415) --- linode_api4/objects/lke.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 55dd0372e..4d3ec5a16 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -37,6 +37,7 @@ class LKEClusterControlPlaneACLAddressesOptions(JSONObject): """ ipv4: Optional[List[str]] = None + ipv6: Optional[List[str]] = None @@ -45,6 +46,8 @@ class LKEClusterControlPlaneACLOptions(JSONObject): """ LKEClusterControlPlaneACLOptions is used to set the ACL configuration of an LKE cluster's control plane. + + NOTE: Control Plane ACLs may not currently be available to all users. """ enabled: Optional[bool] = None @@ -78,6 +81,8 @@ class LKEClusterControlPlaneACL(JSONObject): """ LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's control plane. + + NOTE: Control Plane ACLs may not currently be available to all users. """ enabled: bool = False @@ -264,6 +269,8 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: """ Gets the ACL configuration of this cluster's control plane. + NOTE: Control Plane ACLs may not currently be available to all users. + API Documentation: TODO :returns: The cluster's control plane ACL configuration. @@ -435,6 +442,8 @@ def control_plane_acl_update( """ Updates the ACL configuration for this cluster's control plane. + NOTE: Control Plane ACLs may not currently be available to all users. + API Documentation: TODO :param acl: The ACL configuration to apply to this cluster. @@ -464,6 +473,8 @@ def control_plane_acl_delete(self): This has the same effect as calling control_plane_acl_update with the `enabled` field set to False. Access controls are disabled and all rules are deleted. + NOTE: Control Plane ACLs may not currently be available to all users. + API Documentation: TODO """ self._client.delete( From df2d8c1f221952fd6b58c3d468708ec1df89c34c Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 12 Jun 2024 14:08:56 -0400 Subject: [PATCH 205/379] new: Add support for Linode Disk Encryption (#413) --- linode_api4/groups/linode.py | 18 ++++++- linode_api4/objects/linode.py | 49 +++++++++++++++++- linode_api4/objects/lke.py | 1 + linode_api4/objects/serializable.py | 20 ++++++++ test/fixtures/linode_instances.json | 8 ++- test/fixtures/linode_instances_123_disks.json | 6 ++- ...inode_instances_123_disks_12345_clone.json | 3 +- .../lke_clusters_18881_nodes_123456.json | 2 +- .../lke_clusters_18881_pools_456.json | 3 +- test/integration/helpers.py | 6 ++- test/integration/models/linode/test_linode.py | 50 +++++++++++++++++-- test/integration/models/lke/test_lke.py | 24 +++++++-- test/unit/objects/linode_test.py | 25 ++++++++-- test/unit/objects/lke_test.py | 6 ++- 14 files changed, 197 insertions(+), 24 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 982eede81..e61edcc9c 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,8 +1,9 @@ import base64 import os from collections.abc import Iterable +from typing import Optional, Union -from linode_api4 import Profile +from linode_api4 import InstanceDiskEncryptionType, Profile from linode_api4.common import SSH_KEY_TYPES, load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -131,7 +132,15 @@ def kernels(self, *filters): # create things def instance_create( - self, ltype, region, image=None, authorized_keys=None, **kwargs + self, + ltype, + region, + image=None, + authorized_keys=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, + **kwargs, ): """ Creates a new Linode Instance. This function has several modes of operation: @@ -266,6 +275,8 @@ def instance_create( :type metadata: dict :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall + :param disk_encryption: The disk encryption policy for this Linode. + :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. :type interfaces: list[ConfigInterface] or list[dict[str, Any]] @@ -326,6 +337,9 @@ def instance_create( "authorized_keys": authorized_keys, } + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + params.update(kwargs) result = self.client.post("/linode/instances", data=params) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index f459f5918..323b295b1 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -22,12 +22,25 @@ from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress +from linode_api4.objects.serializable import StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation +class InstanceDiskEncryptionType(StrEnum): + """ + InstanceDiskEncryptionType defines valid values for the + Instance(...).disk_encryption field. + + API Documentation: TODO + """ + + enabled = "enabled" + disabled = "disabled" + + class Backup(DerivedBase): """ A Backup of a Linode Instance. @@ -114,6 +127,7 @@ class Disk(DerivedBase): "filesystem": Property(), "updated": Property(is_datetime=True), "linode_id": Property(identifier=True), + "disk_encryption": Property(), } def duplicate(self): @@ -650,6 +664,8 @@ class Instance(Base): "host_uuid": Property(), "watchdog_enabled": Property(mutable=True), "has_user_data": Property(), + "disk_encryption": Property(), + "lke_cluster_id": Property(), } @property @@ -1343,7 +1359,16 @@ def ip_allocate(self, public=False): i = IPAddress(self._client, result["address"], result) return i - def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): + def rebuild( + self, + image, + root_pass=None, + authorized_keys=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, + **kwargs, + ): """ Rebuilding an Instance deletes all existing Disks and Configs and deploys a new :any:`Image` to it. This can be used to reset an existing @@ -1361,6 +1386,8 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): be a single key, or a path to a file containing the key. :type authorized_keys: list or str + :param disk_encryption: The disk encryption policy for this Linode. + :type disk_encryption: InstanceDiskEncryptionType or str :returns: The newly generated password, if one was not provided (otherwise True) @@ -1378,6 +1405,10 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): "root_pass": root_pass, "authorized_keys": authorized_keys, } + + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + params.update(kwargs) result = self._client.post( @@ -1683,6 +1714,22 @@ def stats(self): "{}/stats".format(Instance.api_endpoint), model=self ) + @property + def lke_cluster(self) -> Optional["LKECluster"]: + """ + Returns the LKE Cluster this Instance is a node of. + + :returns: The LKE Cluster this Instance is a node of. + :rtype: Optional[LKECluster] + """ + + # Local import to prevent circular dependency + from linode_api4.objects.lke import ( # pylint: disable=import-outside-toplevel + LKECluster, + ) + + return LKECluster(self._client, self.lke_cluster_id) + def stats_for(self, dt): """ Returns stats for the month containing the given datetime diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 55dd0372e..6c21dbf1d 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -127,6 +127,7 @@ class LKENodePool(DerivedBase): "cluster_id": Property(identifier=True), "type": Property(slug_relationship=Type), "disks": Property(), + "disk_encryption": Property(), "count": Property(mutable=True), "nodes": Property( volatile=True diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index 15494cdce..b0e7a2503 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,6 @@ import inspect from dataclasses import dataclass +from enum import Enum from types import SimpleNamespace from typing import ( Any, @@ -223,3 +224,22 @@ def __delitem__(self, key): def __len__(self): return len(vars(self)) + + +class StrEnum(str, Enum): + """ + Used for enums that are of type string, which is necessary + for implicit JSON serialization. + + NOTE: Replace this with StrEnum once Python 3.10 has been EOL'd. + See: https://docs.python.org/3/library/enum.html#enum.StrEnum + """ + + def __new__(cls, *values): + value = str(*values) + member = str.__new__(cls, value) + member._value_ = value + return member + + def __str__(self): + return self._value_ diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 3d257938d..a809d8926 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -40,7 +40,9 @@ "image": "linode/ubuntu17.04", "tags": ["something"], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", - "watchdog_enabled": true + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null }, { "group": "test", @@ -79,7 +81,9 @@ "image": "linode/debian9", "tags": [], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", - "watchdog_enabled": false + "watchdog_enabled": false, + "disk_encryption": "enabled", + "lke_cluster_id": 18881 } ] } diff --git a/test/fixtures/linode_instances_123_disks.json b/test/fixtures/linode_instances_123_disks.json index eca5079e5..ddfe7f313 100644 --- a/test/fixtures/linode_instances_123_disks.json +++ b/test/fixtures/linode_instances_123_disks.json @@ -10,7 +10,8 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" }, { "size": 512, @@ -19,7 +20,8 @@ "id": 12346, "updated": "2017-01-01T00:00:00", "label": "512 MB Swap Image", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" } ] } diff --git a/test/fixtures/linode_instances_123_disks_12345_clone.json b/test/fixtures/linode_instances_123_disks_12345_clone.json index 2d378edca..899833e56 100644 --- a/test/fixtures/linode_instances_123_disks_12345_clone.json +++ b/test/fixtures/linode_instances_123_disks_12345_clone.json @@ -5,6 +5,7 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_nodes_123456.json b/test/fixtures/lke_clusters_18881_nodes_123456.json index 311ef3878..646b62f5d 100644 --- a/test/fixtures/lke_clusters_18881_nodes_123456.json +++ b/test/fixtures/lke_clusters_18881_nodes_123456.json @@ -1,5 +1,5 @@ { "id": "123456", - "instance_id": 123458, + "instance_id": 456, "status": "ready" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index ec6b570ac..225023d5d 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -23,5 +23,6 @@ "example tag", "another example" ], - "type": "g6-standard-4" + "type": "g6-standard-4", + "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 5e9d1c441..e0aab06c4 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -79,12 +79,14 @@ def wait_for_condition( # Retry function to help in case of requests sending too quickly before instance is ready -def retry_sending_request(retries: int, condition: Callable, *args) -> object: +def retry_sending_request( + retries: int, condition: Callable, *args, **kwargs +) -> object: curr_t = 0 while curr_t < retries: try: curr_t += 1 - res = condition(*args) + res = condition(*args, **kwargs) return res except ApiError: if curr_t >= retries: diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index a749baad4..1621b9f07 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,4 +1,5 @@ import time +from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, retry_sending_request, @@ -19,7 +20,7 @@ Instance, Type, ) -from linode_api4.objects.linode import MigrationType +from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @pytest.fixture(scope="session") @@ -137,6 +138,30 @@ def create_linode_for_long_running_tests(test_linode_client): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_with_disk_encryption(test_linode_client, request): + client = test_linode_client + + target_region = get_region(client, {"Disk Encryption"}) + timestamp = str(time.time_ns()) + label = "TestSDK-" + timestamp + + disk_encryption = request.param + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + target_region, + image="linode/ubuntu23.04", + label=label, + booted=False, + disk_encryption=disk_encryption, + ) + + yield linode_instance + + linode_instance.delete() + + # Test helper def get_status(linode: Instance, status: str): return linode.status == status @@ -165,8 +190,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Disk Encryption"}) label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( @@ -175,12 +199,18 @@ def test_linode_rebuild(test_linode_client): wait_for_condition(10, 100, get_status, linode, "running") - retry_sending_request(3, linode.rebuild, "linode/debian10") + retry_sending_request( + 3, + linode.rebuild, + "linode/debian10", + disk_encryption=InstanceDiskEncryptionType.disabled, + ) wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" assert linode.image.id == "linode/debian10" + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -383,6 +413,18 @@ def test_linode_volumes(linode_with_volume_firewall): assert "test" in volumes[0].label +@pytest.mark.parametrize( + "linode_with_disk_encryption", ["disabled"], indirect=True +) +def test_linode_with_disk_encryption_disabled(linode_with_disk_encryption): + linode = linode_with_disk_encryption + + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled + assert ( + linode.disks[0].disk_encryption == InstanceDiskEncryptionType.disabled + ) + + def wait_for_disk_status(disk: Disk, timeout): start_time = time.time() while True: diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index bbf87bedf..a3110f0c1 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -1,26 +1,30 @@ import re +from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, send_request_when_resource_available, wait_for_condition, ) +from typing import Any, Dict import pytest from linode_api4 import ( + Instance, + InstanceDiskEncryptionType, LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, ) from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolNode +from linode_api4.objects import LKECluster, LKENodePool @pytest.fixture(scope="session") def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = test_linode_client.regions().first() + region = get_region(test_linode_client, {"Disk Encryption", "Kubernetes"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -37,7 +41,7 @@ def lke_cluster(test_linode_client): def lke_cluster_with_acl(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = test_linode_client.regions().first() + region = get_region(test_linode_client, {"Kubernetes"}) node_pools = test_linode_client.lke.node_pool(node_type, 1) label = get_test_label() + "_cluster" @@ -80,9 +84,21 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): cluster = lke_cluster + wait_for_condition( + 10, + 500, + get_node_status, + cluster, + "ready", + ) + pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) - assert cluster.pools[0].id == pool.id + def _to_comparable(p: LKENodePool) -> Dict[str, Any]: + return {k: v for k, v in p._raw_json.items() if k not in {"nodes"}} + + assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled def test_cluster_dashboard_url_view(lke_cluster): diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 9759bba41..38c34e1ef 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import NetworkInterface +from linode_api4 import InstanceDiskEncryptionType, NetworkInterface from linode_api4.objects import ( Config, ConfigInterface, @@ -36,6 +36,10 @@ def test_get_linode(self): linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8" ) self.assertEqual(linode.watchdog_enabled, True) + self.assertEqual( + linode.disk_encryption, InstanceDiskEncryptionType.disabled + ) + self.assertEqual(linode.lke_cluster_id, None) json = linode._raw_json self.assertIsNotNone(json) @@ -72,7 +76,10 @@ def test_rebuild(self): linode = Instance(self.client, 123) with self.mock_post("/linode/instances/123") as m: - pw = linode.rebuild("linode/debian9") + pw = linode.rebuild( + "linode/debian9", + disk_encryption=InstanceDiskEncryptionType.enabled, + ) self.assertIsNotNone(pw) self.assertTrue(isinstance(pw, str)) @@ -84,6 +91,7 @@ def test_rebuild(self): { "image": "linode/debian9", "root_pass": pw, + "disk_encryption": "enabled", }, ) @@ -306,6 +314,15 @@ def test_transfer_year_month(self): m.call_url, "/linode/instances/123/transfer/2023/4" ) + def test_lke_cluster(self): + """ + Tests that you can grab the parent LKE cluster from an instance node + """ + linode = Instance(self.client, 456) + + assert linode.lke_cluster_id == 18881 + assert linode.lke_cluster.id == linode.lke_cluster_id + def test_duplicate(self): """ Tests that you can submit a correct disk clone api request @@ -318,6 +335,8 @@ def test_duplicate(self): m.call_url, "/linode/instances/123/disks/12345/clone" ) + assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_disk_password(self): """ Tests that you can submit a correct disk password reset api request @@ -393,7 +412,6 @@ def test_create_disk(self): image="linode/debian10", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") - print(m.call_data) self.assertEqual( m.call_data, { @@ -407,6 +425,7 @@ def test_create_disk(self): ) assert disk.id == 12345 + assert disk.disk_encryption == InstanceDiskEncryptionType.disabled def test_instance_create_with_user_data(self): """ diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index a44db97ef..f39fb84ae 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,6 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import InstanceDiskEncryptionType from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -47,6 +48,9 @@ def test_get_pool(self): self.assertEqual(pool.id, 456) self.assertEqual(pool.cluster_id, 18881) self.assertEqual(pool.type.id, "g6-standard-4") + self.assertEqual( + pool.disk_encryption, InstanceDiskEncryptionType.enabled + ) self.assertIsNotNone(pool.disks) self.assertIsNotNone(pool.nodes) self.assertIsNotNone(pool.autoscaler) @@ -84,7 +88,7 @@ def test_node_view(self): self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/123456") self.assertIsNotNone(node) self.assertEqual(node.id, "123456") - self.assertEqual(node.instance_id, 123458) + self.assertEqual(node.instance_id, 456) self.assertEqual(node.status, "ready") def test_node_delete(self): From 2d37008742f0bb4cac4b9b4275dfdcc6396c0296 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 13 Jun 2024 15:20:19 -0400 Subject: [PATCH 206/379] Cleanup duplicated `ObjectStorageGroup` (#417) --- linode_api4/groups/obj.py | 163 -------------------------------------- 1 file changed, 163 deletions(-) delete mode 100644 linode_api4/groups/obj.py diff --git a/linode_api4/groups/obj.py b/linode_api4/groups/obj.py deleted file mode 100644 index 2ca2f0b6c..000000000 --- a/linode_api4/groups/obj.py +++ /dev/null @@ -1,163 +0,0 @@ -from linode_api4.errors import UnexpectedResponseError -from linode_api4.groups import Group -from linode_api4.objects import Base, ObjectStorageCluster, ObjectStorageKeys - - -class ObjectStorageGroup(Group): - """ - This group encapsulates all endpoints under /object-storage, including viewing - available clusters and managing keys. - """ - - def clusters(self, *filters): - """ - Returns a list of available Object Storage Clusters. You may filter - this query to return only Clusters that are available in a specific region:: - - us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") - - API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list - - :param filters: Any number of filters to apply to this query. - See :doc:`Filtering Collections` - for more details on filtering. - - :returns: A list of Object Storage Clusters that matched the query. - :rtype: PaginatedList of ObjectStorageCluster - """ - return self.client._get_and_filter(ObjectStorageCluster, *filters) - - def keys(self, *filters): - """ - Returns a list of Object Storage Keys active on this account. These keys - allow third-party applications to interact directly with Linode Object Storage. - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list - - :param filters: Any number of filters to apply to this query. - See :doc:`Filtering Collections` - for more details on filtering. - - :returns: A list of Object Storage Keys that matched the query. - :rtype: PaginatedList of ObjectStorageKeys - """ - return self.client._get_and_filter(ObjectStorageKeys, *filters) - - def keys_create(self, label, bucket_access=None): - """ - Creates a new Object Storage keypair that may be used to interact directly - with Linode Object Storage in third-party applications. This response is - the only time that "secret_key" will be populated - be sure to capture its - value or it will be lost forever. - - If given, `bucket_access` will cause the new keys to be restricted to only - the specified level of access for the specified buckets. For example, to - create a keypair that can only access the "example" bucket in all clusters - (and assuming you own that bucket in every cluster), you might do this:: - - client = LinodeClient(TOKEN) - - # look up clusters - all_clusters = client.object_storage.clusters() - - new_keys = client.object_storage.keys_create( - "restricted-keys", - bucket_access=[ - client.object_storage.bucket_access(cluster, "example", "read_write") - for cluster in all_clusters - ], - ) - - To create a keypair that can only read from the bucket "example2" in the - "us-east-1" cluster (an assuming you own that bucket in that cluster), - you might do this:: - - client = LinodeClient(TOKEN) - new_keys_2 = client.object_storage.keys_create( - "restricted-keys-2", - bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), - ) - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create - - :param label: The label for this keypair, for identification only. - :type label: str - :param bucket_access: One or a list of dicts with keys "cluster," - "permissions", and "bucket_name". If given, the - resulting Object Storage keys will only have the - requested level of access to the requested buckets, - if they exist and are owned by you. See the provided - :any:`bucket_access` function for a convenient way - to create these dicts. - :type bucket_access: dict or list of dict - - :returns: The new keypair, with the secret key populated. - :rtype: ObjectStorageKeys - """ - params = {"label": label} - - if bucket_access is not None: - if not isinstance(bucket_access, list): - bucket_access = [bucket_access] - - ba = [ - { - "permissions": c.get("permissions"), - "bucket_name": c.get("bucket_name"), - "cluster": ( - c.id - if "cluster" in c - and issubclass(type(c["cluster"]), Base) - else c.get("cluster") - ), - } - for c in bucket_access - ] - - params["bucket_access"] = ba - - result = self.client.post("/object-storage/keys", data=params) - - if not "id" in result: - raise UnexpectedResponseError( - "Unexpected response when creating Object Storage Keys!", - json=result, - ) - - ret = ObjectStorageKeys(self.client, result["id"], result) - return ret - - def bucket_access(self, cluster, bucket_name, permissions): - """ - Returns a dict formatted to be included in the `bucket_access` argument - of :any:`keys_create`. See the docs for that method for an example of - usage. - - :param cluster: The Object Storage cluster to grant access in. - :type cluster: :any:`ObjectStorageCluster` or str - :param bucket_name: The name of the bucket to grant access to. - :type bucket_name: str - :param permissions: The permissions to grant. Should be one of "read_only" - or "read_write". - :type permissions: str - - :returns: A dict formatted correctly for specifying bucket access for - new keys. - :rtype: dict - """ - return { - "cluster": cluster, - "bucket_name": bucket_name, - "permissions": permissions, - } - - def cancel(self): - """ - Cancels Object Storage service. This may be a destructive operation. Once - cancelled, you will no longer receive the transfer for or be billed for - Object Storage, and all keys will be invalidated. - - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel - """ - self.client.post("/object-storage/cancel", data={}) - return True From 54e4dfa48c70adb033010f279b6119ddbc813197 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Mon, 17 Jun 2024 08:45:13 -0700 Subject: [PATCH 207/379] test: Add Calico Inbound and Outbound policies to LKE nodes for E2E (#418) * add calico script for e2e and clean up e2e-test-pr.yml * Edit log messages in shell script * small improvements --- .github/workflows/e2e-test-pr.yml | 45 +++++++++--------- .github/workflows/e2e-test.yml | 15 ++++++ scripts/lke-policy.yaml | 78 +++++++++++++++++++++++++++++++ scripts/lke_calico_rules_e2e.sh | 60 ++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 scripts/lke-policy.yaml create mode 100644 scripts/lke_calico_rules_e2e.sh diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index ac40302c6..9b2113c6e 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -2,8 +2,8 @@ on: pull_request: workflow_dispatch: inputs: - test_path: - description: 'Enter specific test path. E.g. linode_client/test_linode_client.py, models/test_account.py' + test_suite: + description: 'Enter specific test suite. E.g. domain, linode_client' required: false sha: description: 'The hash value of the commit.' @@ -26,7 +26,7 @@ jobs: - uses: actions-ecosystem/action-regex-match@v2 id: validate-tests with: - text: ${{ inputs.test_path }} + text: ${{ inputs.test_suite }} regex: '[^a-z0-9-:.\/_]' # Tests validation flags: gi @@ -71,6 +71,14 @@ jobs: - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + - name: Install Python SDK run: make dev-install env: @@ -80,14 +88,19 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - status=0 - if ! python3 -m pytest test/integration/${INTEGRATION_TEST_PATH} --disable-warnings --junitxml="${report_filename}"; then - echo "EXIT_STATUS=1" >> $GITHUB_ENV - fi + make testint TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + - name: Apply Calico Rules to LKE + if: always() + run: | + cd scripts && ./lke_calico_rules_e2e.sh env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Add additional information to XML report + - name: Upload test results + if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python tod_scripts/add_to_xml_test_report.py \ @@ -95,11 +108,8 @@ jobs: --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" - - - name: Upload test results - run: | - report_filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python3 tod_scripts/test_report_upload_script.py "${report_filename}" + sync + python3 tod_scripts/test_report_upload_script.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} @@ -131,12 +141,3 @@ jobs: conclusion: process.env.conclusion }); return result; - - - name: Test Execution Status Handler - run: | - if [[ "$EXIT_STATUS" != 0 ]]; then - echo "Test execution contains failure(s)" - exit $EXIT_STATUS - else - echo "Tests passed!" - fi \ No newline at end of file diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 0d22f5dd9..7729b6bc2 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -32,6 +32,14 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + - name: Run Integration tests run: | timestamp=$(date +'%Y%m%d%H%M') @@ -40,6 +48,13 @@ jobs: env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + - name: Apply Calico Rules to LKE + if: always() + run: | + cd scripts && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + - name: Upload test results if: always() run: | diff --git a/scripts/lke-policy.yaml b/scripts/lke-policy.yaml new file mode 100644 index 000000000..9859ca8b4 --- /dev/null +++ b/scripts/lke-policy.yaml @@ -0,0 +1,78 @@ +apiVersion: projectcalico.org/v3 +kind: GlobalNetworkPolicy +metadata: + name: lke-rules +spec: + preDNAT: true + applyOnForward: true + order: 100 + # Remember to run calicoctl patch command for this to work + selector: "" + ingress: + # Allow ICMP + - action: Allow + protocol: ICMP + - action: Allow + protocol: ICMPv6 + + # Allow LKE-required ports + - action: Allow + protocol: TCP + destination: + nets: + - 192.168.128.0/17 + - 10.0.0.0/8 + ports: + - 10250 + - 10256 + - 179 + - action: Allow + protocol: UDP + destination: + nets: + - 192.168.128.0/17 + - 10.2.0.0/16 + ports: + - 51820 + + # Allow NodeBalancer ingress to the Node Ports & Allow DNS + - action: Allow + protocol: TCP + source: + nets: + - 192.168.255.0/24 + - 10.0.0.0/8 + destination: + ports: + - 53 + - 30000:32767 + - action: Allow + protocol: UDP + source: + nets: + - 192.168.255.0/24 + - 10.0.0.0/8 + destination: + ports: + - 53 + - 30000:32767 + + # Allow cluster internal communication + - action: Allow + destination: + nets: + - 10.0.0.0/8 + - action: Allow + source: + nets: + - 10.0.0.0/8 + + # 127.0.0.1/32 is needed for kubectl exec and node-shell + - action: Allow + destination: + nets: + - 127.0.0.1/32 + + # Block everything else + - action: Deny + - action: Log diff --git a/scripts/lke_calico_rules_e2e.sh b/scripts/lke_calico_rules_e2e.sh new file mode 100644 index 000000000..48ad5caec --- /dev/null +++ b/scripts/lke_calico_rules_e2e.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +RETRIES=3 +DELAY=30 + +# Function to retry a command with exponential backoff +retry_command() { + local retries=$1 + local wait_time=60 + shift + until "$@"; do + if ((retries == 0)); then + echo "Command failed after multiple retries. Exiting." + exit 1 + fi + echo "Command failed. Retrying in $wait_time seconds..." + sleep $wait_time + ((retries--)) + wait_time=$((wait_time * 2)) + done +} + +# Fetch the list of LKE cluster IDs +CLUSTER_IDS=$(curl -s -H "Authorization: Bearer $LINODE_TOKEN" \ + -H "Content-Type: application/json" \ + "https://api.linode.com/v4/lke/clusters" | jq -r '.data[].id') + +# Check if CLUSTER_IDS is empty +if [ -z "$CLUSTER_IDS" ]; then + echo "All clusters have been cleaned and properly destroyed. No need to apply inbound or outbound rules" + exit 0 +fi + +for ID in $CLUSTER_IDS; do + echo "Applying Calico rules to nodes in Cluster ID: $ID" + + # Download cluster configuration file with retry + for ((i=1; i<=RETRIES; i++)); do + config_response=$(curl -sH "Authorization: Bearer $LINODE_TOKEN" "https://api.linode.com/v4/lke/clusters/$ID/kubeconfig") + if [[ $config_response != *"kubeconfig is not yet available"* ]]; then + echo $config_response | jq -r '.[] | @base64d' > "/tmp/${ID}_config.yaml" + break + fi + echo "Attempt $i to download kubeconfig for cluster $ID failed. Retrying in $DELAY seconds..." + sleep $DELAY + done + + if [[ $config_response == *"kubeconfig is not yet available"* ]]; then + echo "kubeconfig for cluster id:$ID not available after $RETRIES attempts, mostly likely it is an empty cluster. Skipping..." + else + # Export downloaded config file + export KUBECONFIG="/tmp/${ID}_config.yaml" + + retry_command $RETRIES kubectl get nodes + + retry_command $RETRIES calicoctl patch kubecontrollersconfiguration default --allow-version-mismatch --patch='{"spec": {"controllers": {"node": {"hostEndpoint": {"autoCreate": "Enabled"}}}}}' + + retry_command $RETRIES calicoctl apply --allow-version-mismatch -f "$(pwd)/lke-policy.yaml" + fi +done From 3aede444c94e05797efca6246f2f49adef9546e5 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 17 Jun 2024 17:12:36 -0400 Subject: [PATCH 208/379] Cleanup/fix docs, type hints, class ref (#419) --- linode_api4/groups/object_storage.py | 7 +++- linode_api4/objects/object_storage.py | 52 ++------------------------- 2 files changed, 8 insertions(+), 51 deletions(-) diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index 1e5fca65f..bbaf330d9 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -1,3 +1,4 @@ +from typing import List, Optional, Union from urllib import parse from linode_api4.errors import UnexpectedResponseError @@ -53,7 +54,11 @@ def keys(self, *filters): """ return self.client._get_and_filter(ObjectStorageKeys, *filters) - def keys_create(self, label, bucket_access=None): + def keys_create( + self, + label: str, + bucket_access: Optional[Union[dict, List[dict]]] = None, + ): """ Creates a new Object Storage keypair that may be used to interact directly with Linode Object Storage in third-party applications. This response is diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index d9eb32433..022fd2a6b 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -78,12 +78,6 @@ def access_modify( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-modify - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. :type acl: str @@ -126,12 +120,6 @@ def access_update( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-update - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. :type acl: str @@ -168,12 +156,6 @@ def ssl_cert_delete(self): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-delete - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :returns: True if the TLS/SSL certificate and private key in the bucket were successfully deleted. :rtype: bool """ @@ -199,12 +181,6 @@ def ssl_cert(self): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-view - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :returns: A result object which has a bool field indicating if this Bucket has a corresponding TLS/SSL certificate that was uploaded by an Account user. :rtype: MappedObject @@ -234,12 +210,6 @@ def ssl_cert_upload(self, certificate, private_key): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-upload - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param certificate: Your Base64 encoded and PEM formatted SSL certificate. Line breaks must be represented as “\n” in the string for requests (but not when using the Linode CLI) @@ -291,12 +261,6 @@ def contents( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-contents-list - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param marker: The “marker” for this request, which can be used to paginate through large buckets. Its value should be the value of the next_marker property returned with the last page. Listing @@ -357,12 +321,6 @@ def object_acl_config(self, name=None): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-view - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param name: The name of the object for which to retrieve its Access Control List (ACL). Use the Object Storage Bucket Contents List endpoint to access all object names in a bucket. @@ -376,7 +334,7 @@ def object_acl_config(self, name=None): } result = self._client.get( - f"{ObjectStorageBucket.api_endpoint}/object-acl", + f"{type(self).api_endpoint}/object-acl", model=self, data=drop_null_keys(params), ) @@ -400,12 +358,6 @@ def object_acl_config_update(self, acl: ObjectStorageACL, name): API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-update - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str - - :param bucket: The bucket name. - :type bucket: str - :param acl: The Access Control Level of the bucket, as a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. :type acl: str @@ -425,7 +377,7 @@ def object_acl_config_update(self, acl: ObjectStorageACL, name): } result = self._client.put( - f"{ObjectStorageBucket.api_endpoint}/object-acl", + f"{type(self).api_endpoint}/object-acl", model=self, data=params, ) From 78574da0f6712ca84f3a5bcdbde7e312daf48a19 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 18 Jun 2024 12:11:03 -0400 Subject: [PATCH 209/379] Make `ObjectStorageACL` a `StrEnum` (#420) --- linode_api4/objects/object_storage.py | 8 +++++--- linode_api4/objects/serializable.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 022fd2a6b..11df847d6 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,3 +1,4 @@ +from typing import Optional from urllib import parse from linode_api4.errors import UnexpectedResponseError @@ -8,10 +9,11 @@ Property, Region, ) +from linode_api4.objects.serializable import StrEnum from linode_api4.util import drop_null_keys -class ObjectStorageACL: +class ObjectStorageACL(StrEnum): PRIVATE = "private" PUBLIC_READ = "public-read" AUTHENTICATED_READ = "authenticated-read" @@ -67,7 +69,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): def access_modify( self, - acl: ObjectStorageACL = None, + acl: Optional[ObjectStorageACL] = None, cors_enabled=None, ): """ @@ -109,7 +111,7 @@ def access_modify( def access_update( self, - acl: ObjectStorageACL = None, + acl: Optional[ObjectStorageACL] = None, cors_enabled=None, ): """ diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index 15494cdce..b0e7a2503 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,6 @@ import inspect from dataclasses import dataclass +from enum import Enum from types import SimpleNamespace from typing import ( Any, @@ -223,3 +224,22 @@ def __delitem__(self, key): def __len__(self): return len(vars(self)) + + +class StrEnum(str, Enum): + """ + Used for enums that are of type string, which is necessary + for implicit JSON serialization. + + NOTE: Replace this with StrEnum once Python 3.10 has been EOL'd. + See: https://docs.python.org/3/library/enum.html#enum.StrEnum + """ + + def __new__(cls, *values): + value = str(*values) + member = str.__new__(cls, value) + member._value_ = value + return member + + def __str__(self): + return self._value_ From 63f9052ed5cba047540645e72f1f3203cb6c01d3 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 18 Jun 2024 14:27:02 -0400 Subject: [PATCH 210/379] Minor adjustments for prod compatibility --- test/integration/conftest.py | 2 +- test/integration/models/{ => placement}/test_placement.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test/integration/models/{ => placement}/test_placement.py (100%) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 530477123..c9eab20eb 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -439,7 +439,7 @@ def create_placement_group(test_linode_client): pg = client.placement.group_create( "pythonsdk-" + timestamp, - "us-east", + get_region(test_linode_client, {"Placement Group"}), PlacementGroupAffinityType.anti_affinity_local, ) yield pg diff --git a/test/integration/models/test_placement.py b/test/integration/models/placement/test_placement.py similarity index 100% rename from test/integration/models/test_placement.py rename to test/integration/models/placement/test_placement.py From 77e3a84c39758d65793302bbdb4aa3a29706d245 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 18 Jun 2024 14:38:03 -0400 Subject: [PATCH 211/379] Using `api_endpoint` as the URL path (#421) --- linode_api4/objects/object_storage.py | 30 +++++++++++---------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 11df847d6..685925c9b 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -96,10 +96,9 @@ def access_modify( } resp = self._client.post( - "/object-storage/buckets/{}/{}/access".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/access", data=drop_null_keys(params), + model=self, ) if "errors" in resp: @@ -138,10 +137,9 @@ def access_update( } resp = self._client.put( - "/object-storage/buckets/{}/{}/access".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/access", data=drop_null_keys(params), + model=self, ) if "errors" in resp: @@ -163,9 +161,8 @@ def ssl_cert_delete(self): """ resp = self._client.delete( - "/object-storage/buckets/{}/{}/ssl".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ) + f"{self.api_endpoint}/ssl", + model=self, ) if "error" in resp: @@ -188,9 +185,8 @@ def ssl_cert(self): :rtype: MappedObject """ result = self._client.get( - "/object-storage/buckets/{}/{}/ssl".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ) + f"{self.api_endpoint}/ssl", + model=self, ) if not "ssl" in result: @@ -231,10 +227,9 @@ def ssl_cert_upload(self, certificate, private_key): "private_key": private_key, } result = self._client.post( - "/object-storage/buckets/{}/{}/ssl".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/ssl", data=params, + model=self, ) if not "ssl" in result: @@ -298,10 +293,9 @@ def contents( "page_size": page_size, } result = self._client.get( - "/object-storage/buckets/{}/{}/object-list".format( - parse.quote(str(self.cluster)), parse.quote(str(self.id)) - ), + f"{self.api_endpoint}/object-list", data=drop_null_keys(params), + model=self, ) if not "data" in result: From 3168a177886e6a21726a47595b6d72e2d434c8dd Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:52:13 -0700 Subject: [PATCH 212/379] chmod +x calico script (#423) --- scripts/lke_calico_rules_e2e.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/lke_calico_rules_e2e.sh diff --git a/scripts/lke_calico_rules_e2e.sh b/scripts/lke_calico_rules_e2e.sh old mode 100644 new mode 100755 From 92614c91cc0372b591fc9e9035aa7d3d5aa7d528 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 20 Jun 2024 09:49:17 -0400 Subject: [PATCH 213/379] Add Placement Groups LA disclaimer (#422) --- linode_api4/groups/placement.py | 4 ++++ linode_api4/objects/placement.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/linode_api4/groups/placement.py b/linode_api4/groups/placement.py index 20bf0d804..90456fd17 100644 --- a/linode_api4/groups/placement.py +++ b/linode_api4/groups/placement.py @@ -9,6 +9,8 @@ class PlacementAPIGroup(Group): def groups(self, *filters): """ + NOTE: Placement Groups may not currently be available to all users. + Returns a list of Placement Groups on your account. You may filter this query to return only Placement Groups that match specific criteria:: @@ -34,6 +36,8 @@ def group_create( **kwargs, ) -> PlacementGroup: """ + NOTE: Placement Groups may not currently be available to all users. + Create a placement group with the specified parameters. :param label: The label for the placement group. diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py index 876f69ca4..eb5808eee 100644 --- a/linode_api4/objects/placement.py +++ b/linode_api4/objects/placement.py @@ -28,6 +28,8 @@ class PlacementGroupMember(JSONObject): class PlacementGroup(Base): """ + NOTE: Placement Groups may not currently be available to all users. + A VM Placement Group, defining the affinity policy for Linodes created in a region. From 8fe7c51d022485a1eb42590429bd63d0485eff8f Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 26 Jun 2024 13:04:17 -0400 Subject: [PATCH 214/379] Added note in documentation that Parent/Child support may not yet be generally available (#427) --- linode_api4/groups/account.py | 2 ++ linode_api4/objects/account.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index b45152908..21540ea7f 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -502,6 +502,8 @@ def child_accounts(self, *filters): """ Returns a list of all child accounts under the this parent account. + NOTE: Parent/Child related features may not be generally available. + API doc: TBD :returns: a list of all child accounts. diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index aa0a8f57a..8c5ad098f 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -60,6 +60,8 @@ class ChildAccount(Account): """ A child account under a parent account. + NOTE: Parent/Child related features may not be generally available. + API Documentation: TBD """ From 53a2c074fa9da30576d03f6055aca91b76cc4bfe Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 2 Jul 2024 14:22:07 -0700 Subject: [PATCH 215/379] test: Add error handling in e2e_cloud_firewall fixture (#431) * Add some safety to e2e_test_firewall fxiture for ConnectionError * delete comment * Update test/integration/conftest.py Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- test/integration/conftest.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index c9eab20eb..697f33a59 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -6,6 +6,7 @@ import pytest import requests +from requests.exceptions import ConnectionError, RequestException from linode_api4 import ApiError, PlacementGroupAffinityType from linode_api4.linode_client import LinodeClient @@ -68,14 +69,22 @@ def is_valid_ipv6(address): except ipaddress.AddressValueError: return False - def get_public_ip(ip_version="ipv4"): + def get_public_ip(ip_version: str = "ipv4", retries: int = 3): url = ( f"https://api64.ipify.org?format=json" if ip_version == "ipv6" else f"https://api.ipify.org?format=json" ) - response = requests.get(url) - return str(response.json()["ip"]) + for attempt in range(retries): + try: + response = requests.get(url) + response.raise_for_status() + return str(response.json()["ip"]) + except (RequestException, ConnectionError) as e: + if attempt < retries - 1: + time.sleep(2) # Wait before retrying + else: + raise e def create_inbound_rule(ipv4_address, ipv6_address): rule = [ @@ -94,12 +103,19 @@ def create_inbound_rule(ipv4_address, ipv6_address): return rule - # Fetch the public IP addresses + try: + ipv4_address = get_public_ip("ipv4") + except (RequestException, ConnectionError, ValueError, KeyError): + ipv4_address = None - ipv4_address = get_public_ip("ipv4") - ipv6_address = get_public_ip("ipv6") + try: + ipv6_address = get_public_ip("ipv6") + except (RequestException, ConnectionError, ValueError, KeyError): + ipv6_address = None - inbound_rule = create_inbound_rule(ipv4_address, ipv6_address) + inbound_rule = [] + if ipv4_address or ipv6_address: + inbound_rule = create_inbound_rule(ipv4_address, ipv6_address) client = test_linode_client From 4bc4a21bfb3be367a08a97eb259bd1e880816d0f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 5 Jul 2024 19:47:34 -0400 Subject: [PATCH 216/379] project: MultiCluster Object Storage (#416) * Add warnings to deprecated OBJ API usage (#410) * add deprecated * add dependency * fix deprecated * Update OBJ Group and Objects for MultiCluster Object Storage API Changes (#426) --------- Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> --- linode_api4/groups/object_storage.py | 181 ++++++++++++++---- linode_api4/objects/object_storage.py | 42 +++- pyproject.toml | 2 +- ...rage_buckets_us-east-1_example-bucket.json | 1 + test/fixtures/object-storage_keys.json | 32 +++- .../models/object_storage/test_obj.py | 92 +++++++++ test/unit/groups/__init__.py | 0 test/unit/groups/object_storage_test.py | 20 ++ test/unit/linode_client_test.py | 35 ++++ test/unit/objects/object_storage_test.py | 4 +- 10 files changed, 362 insertions(+), 47 deletions(-) create mode 100644 test/integration/models/object_storage/test_obj.py create mode 100644 test/unit/groups/__init__.py create mode 100644 test/unit/groups/object_storage_test.py diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index bbaf330d9..c42805ec1 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -1,6 +1,10 @@ +import re +import warnings from typing import List, Optional, Union from urllib import parse +from deprecated import deprecated + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -9,6 +13,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageKeyPermission, ObjectStorageKeys, ) from linode_api4.util import drop_null_keys @@ -20,8 +25,14 @@ class ObjectStorageGroup(Group): available clusters, buckets, and managing keys and TLS/SSL certs, etc. """ + @deprecated( + reason="deprecated to use regions list API for listing available OJB clusters" + ) def clusters(self, *filters): """ + This endpoint will be deprecated to use the regions list API to list available OBJ clusters, + and a new access key API will directly expose the S3 endpoint hostname. + Returns a list of available Object Storage Clusters. You may filter this query to return only Clusters that are available in a specific region:: @@ -58,6 +69,7 @@ def keys_create( self, label: str, bucket_access: Optional[Union[dict, List[dict]]] = None, + regions: Optional[List[str]] = None, ): """ Creates a new Object Storage keypair that may be used to interact directly @@ -97,14 +109,16 @@ def keys_create( :param label: The label for this keypair, for identification only. :type label: str - :param bucket_access: One or a list of dicts with keys "cluster," - "permissions", and "bucket_name". If given, the - resulting Object Storage keys will only have the - requested level of access to the requested buckets, - if they exist and are owned by you. See the provided - :any:`bucket_access` function for a convenient way - to create these dicts. - :type bucket_access: dict or list of dict + :param bucket_access: One or a list of dicts with keys "cluster," "region", + "permissions", and "bucket_name". "cluster" key is + deprecated because multiple cluster can be placed + in the same region. Please consider switching to + regions. If given, the resulting Object Storage keys + will only have the requested level of access to the + requested buckets, if they exist and are owned by + you. See the provided :any:`bucket_access` function + for a convenient way to create these dicts. + :type bucket_access: Optional[Union[dict, List[dict]]] :returns: The new keypair, with the secret key populated. :rtype: ObjectStorageKeys @@ -115,22 +129,35 @@ def keys_create( if not isinstance(bucket_access, list): bucket_access = [bucket_access] - ba = [ - { - "permissions": c.get("permissions"), - "bucket_name": c.get("bucket_name"), - "cluster": ( - c.id - if "cluster" in c - and issubclass(type(c["cluster"]), Base) - else c.get("cluster") - ), + ba = [] + for access_rule in bucket_access: + access_rule_json = { + "permissions": access_rule.get("permissions"), + "bucket_name": access_rule.get("bucket_name"), } - for c in bucket_access - ] + + if "region" in access_rule: + access_rule_json["region"] = access_rule.get("region") + elif "cluster" in access_rule: + warnings.warn( + "'cluster' is a deprecated attribute, " + "please consider using 'region' instead.", + DeprecationWarning, + ) + access_rule_json["cluster"] = ( + access_rule.id + if "cluster" in access_rule + and issubclass(type(access_rule["cluster"]), Base) + else access_rule.get("cluster") + ) + + ba.append(access_rule_json) params["bucket_access"] = ba + if regions is not None: + params["regions"] = regions + result = self.client.post("/object-storage/keys", data=params) if not "id" in result: @@ -142,9 +169,74 @@ def keys_create( ret = ObjectStorageKeys(self.client, result["id"], result) return ret - def bucket_access(self, cluster, bucket_name, permissions): - return ObjectStorageBucket.access( - self, cluster, bucket_name, permissions + @classmethod + def bucket_access( + cls, + cluster_or_region: str, + bucket_name: str, + permissions: Union[str, ObjectStorageKeyPermission], + ): + """ + Returns a dict formatted to be included in the `bucket_access` argument + of :any:`keys_create`. See the docs for that method for an example of + usage. + + :param cluster_or_region: The region or Object Storage cluster to grant access in. + :type cluster_or_region: str + :param bucket_name: The name of the bucket to grant access to. + :type bucket_name: str + :param permissions: The permissions to grant. Should be one of "read_only" + or "read_write". + :type permissions: Union[str, ObjectStorageKeyPermission] + :param use_region: Whether to use region mode. + :type use_region: bool + + :returns: A dict formatted correctly for specifying bucket access for + new keys. + :rtype: dict + """ + + result = { + "bucket_name": bucket_name, + "permissions": permissions, + } + + if cls.is_cluster(cluster_or_region): + warnings.warn( + "Cluster ID for Object Storage APIs has been deprecated. " + "Please consider switch to a region ID (e.g., from `us-mia-1` to `us-mia`)", + DeprecationWarning, + ) + result["cluster"] = cluster_or_region + else: + result["region"] = cluster_or_region + + return result + + def buckets_in_region(self, region: str, *filters): + """ + Returns a list of Buckets in the region belonging to this Account. + + This endpoint is available for convenience. + It is recommended that instead you use the more fully-featured S3 API directly. + + API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-in-cluster-list + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :param region: The ID of an object storage region (e.g. `us-mia-1`). + :type region: str + + :returns: A list of Object Storage Buckets that in the requested cluster. + :rtype: PaginatedList of ObjectStorageBucket + """ + + return self.client._get_and_filter( + ObjectStorageBucket, + *filters, + endpoint=f"/object-storage/buckets/{region}", ) def cancel(self): @@ -197,10 +289,14 @@ def buckets(self, *filters): """ return self.client._get_and_filter(ObjectStorageBucket, *filters) + @staticmethod + def is_cluster(cluster_or_region: str): + return bool(re.match(r"^[a-z]{2}-[a-z]+-[0-9]+$", cluster_or_region)) + def bucket_create( self, - cluster, - label, + cluster_or_region: Union[str, ObjectStorageCluster], + label: str, acl: ObjectStorageACL = ObjectStorageACL.PRIVATE, cors_enabled=False, ): @@ -240,17 +336,30 @@ def bucket_create( :returns: A Object Storage Buckets that created by user. :rtype: ObjectStorageBucket """ - cluster_id = ( - cluster.id if isinstance(cluster, ObjectStorageCluster) else cluster + cluster_or_region_id = ( + cluster_or_region.id + if isinstance(cluster_or_region, ObjectStorageCluster) + else cluster_or_region ) params = { - "cluster": cluster_id, "label": label, "acl": acl, "cors_enabled": cors_enabled, } + if self.is_cluster(cluster_or_region_id): + warnings.warn( + "The cluster parameter has been deprecated for creating a object " + "storage bucket. Please consider switching to a region value. For " + "example, a cluster value of `us-mia-1` can be translated to a " + "region value of `us-mia`.", + DeprecationWarning, + ) + params["cluster"] = cluster_or_region_id + else: + params["region"] = cluster_or_region_id + result = self.client.post("/object-storage/buckets", data=params) if not "label" in result or not "cluster" in result: @@ -263,21 +372,21 @@ def bucket_create( self.client, result["label"], result["cluster"], result ) - def object_acl_config(self, cluster_id, bucket, name=None): + def object_acl_config(self, cluster_or_region_id: str, bucket, name=None): return ObjectStorageBucket( - self.client, bucket, cluster_id + self.client, bucket, cluster_or_region_id ).object_acl_config(name) def object_acl_config_update( - self, cluster_id, bucket, acl: ObjectStorageACL, name + self, cluster_or_region_id, bucket, acl: ObjectStorageACL, name ): return ObjectStorageBucket( - self.client, bucket, cluster_id + self.client, bucket, cluster_or_region_id ).object_acl_config_update(acl, name) def object_url_create( self, - cluster_id, + cluster_or_region_id, bucket, method, name, @@ -294,8 +403,8 @@ def object_url_create( API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-url-create - :param cluster_id: The ID of the cluster this bucket exists in. - :type cluster_id: str + :param cluster_or_region_id: The ID of the cluster or region this bucket exists in. + :type cluster_or_region_id: str :param bucket: The bucket name. :type bucket: str @@ -337,7 +446,7 @@ def object_url_create( result = self.client.post( "/object-storage/buckets/{}/{}/object-url".format( - parse.quote(str(cluster_id)), parse.quote(str(bucket)) + parse.quote(str(cluster_or_region_id)), parse.quote(str(bucket)) ), data=drop_null_keys(params), ) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 685925c9b..2cbcf59bd 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,6 +1,8 @@ from typing import Optional from urllib import parse +from deprecated import deprecated + from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -21,6 +23,11 @@ class ObjectStorageACL(StrEnum): CUSTOM = "custom" +class ObjectStorageKeyPermission(StrEnum): + READ_ONLY = "read_only" + READ_WRITE = "read_write" + + class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. @@ -28,12 +35,13 @@ class ObjectStorageBucket(DerivedBase): API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-view """ - api_endpoint = "/object-storage/buckets/{cluster}/{label}" - parent_id_name = "cluster" + api_endpoint = "/object-storage/buckets/{region}/{label}" + parent_id_name = "region" id_attribute = "label" properties = { - "cluster": Property(identifier=True), + "region": Property(identifier=True), + "cluster": Property(), "created": Property(is_datetime=True), "hostname": Property(), "label": Property(identifier=True), @@ -57,8 +65,11 @@ def make_instance(cls, id, client, parent_id=None, json=None): """ if json is None: return None - if parent_id is None and json["cluster"]: - parent_id = json["cluster"] + + cluster_or_region = json.get("region") or json.get("cluster") + + if parent_id is None and cluster_or_region: + parent_id = cluster_or_region if parent_id: return super().make(id, client, cls, parent_id=parent_id, json=json) @@ -386,6 +397,13 @@ def object_acl_config_update(self, acl: ObjectStorageACL, name): return MappedObject(**result) + @deprecated( + reason=( + "'access' method has been deprecated in favor of the class method " + "'bucket_access' in ObjectStorageGroup, which can be accessed by " + "'client.object_storage.access'" + ) + ) def access(self, cluster, bucket_name, permissions): """ Returns a dict formatted to be included in the `bucket_access` argument @@ -411,8 +429,14 @@ def access(self, cluster, bucket_name, permissions): } +@deprecated( + reason="deprecated to use regions list API for viewing available OJB clusters" +) class ObjectStorageCluster(Base): """ + This class will be deprecated to use the regions list to view available OBJ clusters, + and a new access key API will directly expose the S3 endpoint hostname. + A cluster where Object Storage is available. API documentation: https://www.linode.com/docs/api/object-storage/#cluster-view @@ -428,6 +452,13 @@ class ObjectStorageCluster(Base): "static_site_domain": Property(), } + @deprecated( + reason=( + "'buckets_in_cluster' method has been deprecated, please consider " + "switching to 'buckets_in_region' in the object storage group (can " + "be accessed via 'client.object_storage.buckets_in_cluster')." + ) + ) def buckets_in_cluster(self, *filters): """ Returns a list of Buckets in this cluster belonging to this Account. @@ -470,4 +501,5 @@ class ObjectStorageKeys(Base): "secret_key": Property(), "bucket_access": Property(), "limited": Property(), + "regions": Property(unordered=True), } diff --git a/pyproject.toml b/pyproject.toml index 4e2c60f00..ea96865c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["requests", "polling"] +dependencies = ["requests", "polling", "deprecated"] dynamic = ["version"] [project.optional-dependencies] diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json index b8c9450b6..bb93ec99a 100644 --- a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json @@ -1,5 +1,6 @@ { "cluster": "us-east-1", + "region": "us-east", "created": "2019-01-01T01:23:45", "hostname": "example-bucket.us-east-1.linodeobjects.com", "label": "example-bucket", diff --git a/test/fixtures/object-storage_keys.json b/test/fixtures/object-storage_keys.json index da6c2278a..0a9181658 100644 --- a/test/fixtures/object-storage_keys.json +++ b/test/fixtures/object-storage_keys.json @@ -6,14 +6,40 @@ "id": 1, "label": "object-storage-key-1", "secret_key": "[REDACTED]", - "access_key": "testAccessKeyHere123" + "access_key": "testAccessKeyHere123", + "limited": false, + "regions": [ + { + "id": "us-east", + "s3_endpoint": "us-east-1.linodeobjects.com" + }, + { + "id": "us-west", + "s3_endpoint": "us-west-123.linodeobjects.com" + } + ] }, { "id": 2, "label": "object-storage-key-2", "secret_key": "[REDACTED]", - "access_key": "testAccessKeyHere456" + "access_key": "testAccessKeyHere456", + "limited": true, + "bucket_access": [ + { + "cluster": "us-mia-1", + "bucket_name": "example-bucket", + "permissions": "read_only", + "region": "us-mia" + } + ], + "regions": [ + { + "id": "us-mia", + "s3_endpoint": "us-mia-1.linodeobjects.com" + } + ] } ], "page": 1 -} +} \ No newline at end of file diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py new file mode 100644 index 000000000..863eda129 --- /dev/null +++ b/test/integration/models/object_storage/test_obj.py @@ -0,0 +1,92 @@ +import time +from test.integration.conftest import get_region + +import pytest + +from linode_api4.linode_client import LinodeClient +from linode_api4.objects.object_storage import ( + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageKeyPermission, + ObjectStorageKeys, +) + + +@pytest.fixture(scope="session") +def region(test_linode_client: LinodeClient): + return get_region(test_linode_client, {"Object Storage"}).id + + +@pytest.fixture(scope="session") +def bucket(test_linode_client: LinodeClient, region: str): + bucket = test_linode_client.object_storage.bucket_create( + cluster_or_region=region, + label="bucket-" + str(time.time_ns()), + acl=ObjectStorageACL.PRIVATE, + cors_enabled=False, + ) + + yield bucket + bucket.delete() + + +@pytest.fixture(scope="session") +def obj_key(test_linode_client: LinodeClient): + key = test_linode_client.object_storage.keys_create( + label="obj-key-" + str(time.time_ns()), + ) + + yield key + key.delete() + + +@pytest.fixture(scope="session") +def obj_limited_key( + test_linode_client: LinodeClient, region: str, bucket: ObjectStorageBucket +): + key = test_linode_client.object_storage.keys_create( + label="obj-limited-key-" + str(time.time_ns()), + bucket_access=test_linode_client.object_storage.bucket_access( + cluster_or_region=region, + bucket_name=bucket.label, + permissions=ObjectStorageKeyPermission.READ_ONLY, + ), + regions=[region], + ) + + yield key + key.delete() + + +def test_keys( + test_linode_client: LinodeClient, + obj_key: ObjectStorageKeys, + obj_limited_key: ObjectStorageKeys, +): + loaded_key = test_linode_client.load(ObjectStorageKeys, obj_key.id) + loaded_limited_key = test_linode_client.load( + ObjectStorageKeys, obj_limited_key.id + ) + + assert loaded_key.label == obj_key.label + assert loaded_limited_key.label == obj_limited_key.label + + +def test_bucket( + test_linode_client: LinodeClient, + bucket: ObjectStorageBucket, +): + loaded_bucket = test_linode_client.load(ObjectStorageBucket, bucket.label) + + assert loaded_bucket.label == bucket.label + assert loaded_bucket.region == bucket.region + + +def test_bucket( + test_linode_client: LinodeClient, + bucket: ObjectStorageBucket, + region: str, +): + buckets = test_linode_client.object_storage.buckets_in_region(region=region) + assert len(buckets) >= 1 + assert any(b.label == bucket.label for b in buckets) diff --git a/test/unit/groups/__init__.py b/test/unit/groups/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/unit/groups/object_storage_test.py b/test/unit/groups/object_storage_test.py new file mode 100644 index 000000000..31c931498 --- /dev/null +++ b/test/unit/groups/object_storage_test.py @@ -0,0 +1,20 @@ +import pytest + +from linode_api4.groups.object_storage import ObjectStorageGroup + + +@pytest.mark.parametrize( + "cluster_or_region,is_cluster", + [ + ("us-east-1", True), + ("us-central-1", True), + ("us-mia-1", True), + ("us-iad-123", True), + ("us-east", False), + ("us-central", False), + ("us-mia", False), + ("us-iad", False), + ], +) +def test_is_cluster(cluster_or_region: str, is_cluster: bool): + assert ObjectStorageGroup.is_cluster(cluster_or_region) == is_cluster diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 3facd2e95..081b27d09 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -980,6 +980,41 @@ def test_keys_create(self): self.assertEqual(m.call_url, "/object-storage/keys") self.assertEqual(m.call_data, {"label": "object-storage-key-1"}) + def test_limited_keys_create(self): + """ + Tests that you can create Object Storage Keys + """ + with self.mock_post("object-storage/keys/2") as m: + keys = self.client.object_storage.keys_create( + "object-storage-key-1", + self.client.object_storage.bucket_access( + "us-east", + "example-bucket", + "read_only", + ), + ["us-east"], + ) + + self.assertIsNotNone(keys) + self.assertEqual(keys.id, 2) + self.assertEqual(keys.label, "object-storage-key-2") + + self.assertEqual(m.call_url, "/object-storage/keys") + self.assertEqual( + m.call_data, + { + "label": "object-storage-key-1", + "bucket_access": [ + { + "permissions": "read_only", + "bucket_name": "example-bucket", + "region": "us-east", + } + ], + "regions": ["us-east"], + }, + ) + def test_transfer(self): """ Test that you can get the amount of outbound data transfer diff --git a/test/unit/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py index 59317afa1..95d781a84 100644 --- a/test/unit/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -53,11 +53,11 @@ def test_bucket_access_modify(self): Test that you can modify bucket access settings. """ bucket_access_modify_url = ( - "/object-storage/buckets/us-east-1/example-bucket/access" + "/object-storage/buckets/us-east/example-bucket/access" ) with self.mock_post({}) as m: object_storage_bucket = ObjectStorageBucket( - self.client, "example-bucket", "us-east-1" + self.client, "example-bucket", "us-east" ) object_storage_bucket.access_modify(ObjectStorageACL.PRIVATE, True) self.assertEqual( From 2d9a09e5e55c6ae9a4d102323d8b1551975e04ac Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:59:52 -0600 Subject: [PATCH 217/379] test: Add missing integration tests to improve coverage for identified gaps (#429) * Fill in test gaps in models and improve fixtures * address pr comment and add one more test * make format --- test/integration/conftest.py | 13 ++- .../linode_client/test_linode_client.py | 28 +---- .../login_client/test_login_client.py | 2 +- .../models/account/test_account.py | 24 +++- test/integration/models/lke/test_lke.py | 11 +- .../models/longview/test_longview.py | 29 ++++- .../models/objectstorage/test_obj_storage.py | 105 ++++++++++++++++++ .../models/profile/test_profile.py | 36 ++++++ 8 files changed, 218 insertions(+), 30 deletions(-) create mode 100644 test/integration/models/objectstorage/test_obj_storage.py create mode 100644 test/integration/models/profile/test_profile.py diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 697f33a59..3638bd57d 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -27,6 +27,13 @@ def get_api_url(): return os.environ.get(ENV_API_URL_NAME, "https://api.linode.com/v4beta") +def get_random_label(): + timestamp = str(time.time_ns())[:-5] + label = "label_" + timestamp + + return label + + def get_region(client: LinodeClient, capabilities: Set[str] = None): region_override = os.environ.get(ENV_REGION_OVERRIDE) @@ -329,7 +336,7 @@ def test_sshkey(test_linode_client, ssh_key_gen): @pytest.fixture -def ssh_keys_object_storage(test_linode_client): +def access_keys_object_storage(test_linode_client): client = test_linode_client label = "TestSDK-obj-storage-key" key = client.object_storage.keys_create(label) @@ -364,8 +371,10 @@ def test_firewall(test_linode_client): @pytest.fixture def test_oauth_client(test_linode_client): client = test_linode_client + label = get_random_label() + "_oauth" + oauth_client = client.account.oauth_client_create( - "test-oauth-client", "https://localhost/oauth/callback" + label, "https://localhost/oauth/callback" ) yield oauth_client diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index c9ce35d6e..df634cf06 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -353,26 +353,6 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): assert e.status == 400 -# ProfileGroupTest - - -def test_get_sshkeys(test_linode_client, test_sshkey): - client = test_linode_client - - ssh_keys = client.profile.ssh_keys() - - ssh_labels = [i.label for i in ssh_keys] - - assert test_sshkey.label in ssh_labels - - -def test_ssh_key_create(test_sshkey, ssh_key_gen): - pub_key = ssh_key_gen[0] - key = test_sshkey - - assert pub_key == key._raw_json["ssh_key"] - - # ObjectStorageGroupTests @@ -385,9 +365,9 @@ def test_get_object_storage_clusters(test_linode_client): assert "us-east" in clusters[0].region.id -def test_get_keys(test_linode_client, ssh_keys_object_storage): +def test_get_keys(test_linode_client, access_keys_object_storage): client = test_linode_client - key = ssh_keys_object_storage + key = access_keys_object_storage keys = client.object_storage.keys() key_labels = [i.label for i in keys] @@ -395,8 +375,8 @@ def test_get_keys(test_linode_client, ssh_keys_object_storage): assert key.label in key_labels -def test_keys_create(test_linode_client, ssh_keys_object_storage): - key = ssh_keys_object_storage +def test_keys_create(test_linode_client, access_keys_object_storage): + key = access_keys_object_storage assert type(key) == type( ObjectStorageKeys(client=test_linode_client, id="123") diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py index 8631c2617..7cb4246ea 100644 --- a/test/integration/login_client/test_login_client.py +++ b/test/integration/login_client/test_login_client.py @@ -32,7 +32,7 @@ def test_get_oathclient(test_linode_client, test_oauth_client): oauth_client = client.load(OAuthClient, test_oauth_client.id) - assert "test-oauth-client" == oauth_client.label + assert "_oauth" in test_oauth_client.label assert "https://localhost/oauth/callback" == oauth_client.redirect_uri diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 337718709..a9dce4a3a 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -1,4 +1,5 @@ import time +from datetime import datetime from test.integration.helpers import get_test_label import pytest @@ -91,7 +92,6 @@ def test_get_user(test_linode_client): assert username == user.username assert "email" in user._raw_json - assert "email" in user._raw_json def test_list_child_accounts(test_linode_client): @@ -102,3 +102,25 @@ def test_list_child_accounts(test_linode_client): child_account = ChildAccount(client, child_accounts[0].euuid) child_account._api_get() child_account.create_token() + + +def test_get_invoice(test_linode_client): + client = test_linode_client + + invoices = client.account.invoices() + + if len(invoices) > 0: + assert isinstance(invoices[0].subtotal, float) + assert isinstance(invoices[0].tax, float) + assert isinstance(invoices[0].total, float) + assert r"'billing_source': 'linode'" in str(invoices[0]._raw_json) + + +def test_get_payments(test_linode_client): + client = test_linode_client + + payments = client.account.payments() + + if len(payments) > 0: + assert isinstance(payments[0].date, datetime) + assert isinstance(payments[0].usd, float) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 4967c067f..2e74c8205 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -1,3 +1,4 @@ +import base64 import re from test.integration.helpers import ( get_test_label, @@ -95,9 +96,17 @@ def test_cluster_dashboard_url_view(lke_cluster): assert re.search("https://+", url) -def test_kubeconfig_delete(lke_cluster): +def test_get_and_delete_kubeconfig(lke_cluster): cluster = lke_cluster + kubeconfig_encoded = cluster.kubeconfig + + kubeconfig_decoded = base64.b64decode(kubeconfig_encoded).decode("utf-8") + + assert "kind: Config" in kubeconfig_decoded + + assert "apiVersion:" in kubeconfig_decoded + res = send_request_when_resource_available(300, cluster.kubeconfig_delete) assert res is None diff --git a/test/integration/models/longview/test_longview.py b/test/integration/models/longview/test_longview.py index 0fb7daf7f..f04875e63 100644 --- a/test/integration/models/longview/test_longview.py +++ b/test/integration/models/longview/test_longview.py @@ -3,7 +3,12 @@ import pytest -from linode_api4.objects import LongviewClient, LongviewSubscription +from linode_api4.objects import ( + ApiError, + LongviewClient, + LongviewPlan, + LongviewSubscription, +) @pytest.mark.smoke @@ -46,3 +51,25 @@ def test_get_longview_subscription(test_linode_client, test_longview_client): assert re.search("[0-9]+", str(sub.price.hourly)) assert re.search("[0-9]+", str(sub.price.monthly)) + + assert "longview-3" in str(subs.lists) + assert "longview-10" in str(subs.lists) + assert "longview-40" in str(subs.lists) + assert "longview-100" in str(subs.lists) + + +def test_longview_plan_update_method_not_allowed(test_linode_client): + try: + test_linode_client.longview.longview_plan_update("longview-100") + except ApiError as e: + assert e.status == 405 + assert "Method Not Allowed" in str(e) + + +def test_get_current_longview_plan(test_linode_client): + lv_plan = test_linode_client.load(LongviewPlan, "") + + if lv_plan.label is not None: + assert "Longview" in lv_plan.label + assert "hourly" in lv_plan.price.dict + assert "monthly" in lv_plan.price.dict diff --git a/test/integration/models/objectstorage/test_obj_storage.py b/test/integration/models/objectstorage/test_obj_storage.py new file mode 100644 index 000000000..e040a9d1d --- /dev/null +++ b/test/integration/models/objectstorage/test_obj_storage.py @@ -0,0 +1,105 @@ +import time +from test.integration.conftest import get_region + +import pytest + +from linode_api4.objects import ( + ObjectStorageACL, + ObjectStorageBucket, + ObjectStorageCluster, + ObjectStorageKeys, +) + + +@pytest.fixture(scope="session") +def test_object_storage_bucket(test_linode_client): + client = test_linode_client + + region = get_region(client, {"Object Storage"}) + cluster_region_name = region.id + "-1" + label = str(time.time_ns())[:-5] + "-bucket" + + bucket = client.object_storage.bucket_create( + cluster=cluster_region_name, label=label + ) + + yield bucket + + bucket.delete() + + +def test_list_obj_storage_bucket( + test_linode_client, test_object_storage_bucket +): + client = test_linode_client + + buckets = client.object_storage.buckets() + target_bucket = test_object_storage_bucket + + bucket_ids = [bucket.id for bucket in buckets] + + assert target_bucket.id in bucket_ids + assert isinstance(target_bucket, ObjectStorageBucket) + + +def test_bucket_access_modify(test_object_storage_bucket): + bucket = test_object_storage_bucket + + res = bucket.access_modify(ObjectStorageACL.PRIVATE, cors_enabled=True) + + assert res + + +def test_bucket_access_update(test_object_storage_bucket): + bucket = test_object_storage_bucket + res = bucket.access_update(ObjectStorageACL.PRIVATE, cors_enabled=True) + + assert res + + +def test_get_ssl_cert(test_object_storage_bucket): + bucket = test_object_storage_bucket + + res = bucket.ssl_cert().ssl + + assert res is False + + +def test_create_key_for_specific_bucket( + test_linode_client, test_object_storage_bucket +): + client = test_linode_client + bucket = test_object_storage_bucket + keys = client.object_storage.keys_create( + "restricted-keys", + bucket_access=client.object_storage.bucket_access( + bucket.cluster, bucket.id, "read_write" + ), + ) + + assert isinstance(keys, ObjectStorageKeys) + assert keys.bucket_access[0].bucket_name == bucket.id + assert keys.bucket_access[0].permissions == "read_write" + assert keys.bucket_access[0].cluster == bucket.cluster + + +def test_get_cluster(test_linode_client, test_object_storage_bucket): + client = test_linode_client + bucket = test_object_storage_bucket + + cluster = client.load(ObjectStorageCluster, bucket.cluster) + + assert "linodeobjects.com" in cluster.domain + assert cluster.id == bucket.cluster + assert "available" == cluster.status + + +def test_get_buckets_in_cluster(test_linode_client, test_object_storage_bucket): + client = test_linode_client + bucket = test_object_storage_bucket + + cluster = client.load(ObjectStorageCluster, bucket.cluster) + buckets = cluster.buckets_in_cluster() + bucket_ids = [bucket.id for bucket in buckets] + + assert bucket.id in bucket_ids diff --git a/test/integration/models/profile/test_profile.py b/test/integration/models/profile/test_profile.py new file mode 100644 index 000000000..cafec12ea --- /dev/null +++ b/test/integration/models/profile/test_profile.py @@ -0,0 +1,36 @@ +from linode_api4.objects import PersonalAccessToken, Profile, SSHKey + + +def test_user_profile(test_linode_client): + client = test_linode_client + + profile = client.profile() + + assert isinstance(profile, Profile) + + +def test_get_personal_access_token_objects(test_linode_client): + client = test_linode_client + + personal_access_tokens = client.profile.tokens() + + if len(personal_access_tokens) > 0: + assert isinstance(personal_access_tokens[0], PersonalAccessToken) + + +def test_get_sshkeys(test_linode_client, test_sshkey): + client = test_linode_client + + ssh_keys = client.profile.ssh_keys() + + ssh_labels = [i.label for i in ssh_keys] + + assert isinstance(test_sshkey, SSHKey) + assert test_sshkey.label in ssh_labels + + +def test_ssh_key_create(test_sshkey, ssh_key_gen): + pub_key = ssh_key_gen[0] + key = test_sshkey + + assert pub_key == key._raw_json["ssh_key"] From 10d3685d251173b1c85f997595f061c6643c822b Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Sun, 7 Jul 2024 13:04:43 -0600 Subject: [PATCH 218/379] add minimal test account option in e2e-test.yml (#430) --- .github/workflows/e2e-test.yml | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 7729b6bc2..abf5fb209 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -1,7 +1,16 @@ name: Integration Tests on: - workflow_dispatch: null + workflow_dispatch: + inputs: + use_minimal_test_account: + description: 'Use minimal test account' + required: false + default: 'false' + sha: + description: 'The hash value of the commit' + required: false + default: '' push: branches: - main @@ -13,7 +22,16 @@ jobs: env: EXIT_STATUS: 0 steps: - - name: Clone Repository + - name: Clone Repository with SHA + if: ${{ inputs.sha != '' }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + ref: ${{ inputs.sha }} + + - name: Clone Repository without SHA + if: ${{ inputs.sha == '' }} uses: actions/checkout@v4 with: fetch-depth: 0 @@ -40,20 +58,24 @@ jobs: mv calicoctl-linux-amd64 /usr/local/bin/calicoctl mv kubectl /usr/local/bin/kubectl + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + - name: Run Integration tests run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" make testint TEST_ARGS="--junitxml=${report_filename}" env: - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - name: Apply Calico Rules to LKE if: always() run: | cd scripts && ./lke_calico_rules_e2e.sh env: - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - name: Upload test results if: always() From 639588db861966ca5cb5ec8ba80d996b4f4454e4 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 10 Jul 2024 10:26:27 -0400 Subject: [PATCH 219/379] Linodes and OBJ Integration Tests Improvements (#434) * Improve and deduplicate object storage tests * Improve linode save test * type annotations for clients --- test/integration/models/linode/test_linode.py | 6 +- .../models/object_storage/test_obj.py | 39 +++++++ .../models/objectstorage/test_obj_storage.py | 105 ------------------ 3 files changed, 42 insertions(+), 108 deletions(-) delete mode 100644 test/integration/models/objectstorage/test_obj_storage.py diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index a18fede11..01f3aaa16 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -584,7 +584,7 @@ def test_get_linode_types_overrides(test_linode_client): def test_save_linode_noforce(test_linode_client, create_linode): linode = create_linode old_label = linode.label - linode.label = "updated_no_force_label" + linode.label = old_label + "updated_no_force" linode.save(force=False) linode = test_linode_client.load(Instance, linode.id) @@ -595,8 +595,8 @@ def test_save_linode_noforce(test_linode_client, create_linode): def test_save_linode_force(test_linode_client, create_linode): linode = create_linode old_label = linode.label - linode.label = "updated_force_label" - linode.save(force=False) + linode.label = old_label + "updated_force" + linode.save(force=True) linode = test_linode_client.load(Instance, linode.id) diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 863eda129..3042f326a 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -7,6 +7,7 @@ from linode_api4.objects.object_storage import ( ObjectStorageACL, ObjectStorageBucket, + ObjectStorageCluster, ObjectStorageKeyPermission, ObjectStorageKeys, ) @@ -90,3 +91,41 @@ def test_bucket( buckets = test_linode_client.object_storage.buckets_in_region(region=region) assert len(buckets) >= 1 assert any(b.label == bucket.label for b in buckets) + + +def test_list_obj_storage_bucket( + test_linode_client: LinodeClient, + bucket: ObjectStorageBucket, +): + buckets = test_linode_client.object_storage.buckets() + target_bucket_id = bucket.id + assert any(target_bucket_id == b.id for b in buckets) + + +def test_bucket_access_modify(bucket: ObjectStorageBucket): + bucket.access_modify(ObjectStorageACL.PRIVATE, cors_enabled=True) + + +def test_bucket_access_update(bucket: ObjectStorageBucket): + bucket.access_update(ObjectStorageACL.PRIVATE, cors_enabled=True) + + +def test_get_ssl_cert(bucket: ObjectStorageBucket): + assert not bucket.ssl_cert().ssl + + +def test_get_cluster( + test_linode_client: LinodeClient, bucket: ObjectStorageBucket +): + cluster = test_linode_client.load(ObjectStorageCluster, bucket.cluster) + + assert "linodeobjects.com" in cluster.domain + assert cluster.id == bucket.cluster + assert "available" == cluster.status + + +def test_get_buckets_in_cluster( + test_linode_client: LinodeClient, bucket: ObjectStorageBucket +): + cluster = test_linode_client.load(ObjectStorageCluster, bucket.cluster) + assert any(bucket.id == b.id for b in cluster.buckets_in_cluster()) diff --git a/test/integration/models/objectstorage/test_obj_storage.py b/test/integration/models/objectstorage/test_obj_storage.py deleted file mode 100644 index e040a9d1d..000000000 --- a/test/integration/models/objectstorage/test_obj_storage.py +++ /dev/null @@ -1,105 +0,0 @@ -import time -from test.integration.conftest import get_region - -import pytest - -from linode_api4.objects import ( - ObjectStorageACL, - ObjectStorageBucket, - ObjectStorageCluster, - ObjectStorageKeys, -) - - -@pytest.fixture(scope="session") -def test_object_storage_bucket(test_linode_client): - client = test_linode_client - - region = get_region(client, {"Object Storage"}) - cluster_region_name = region.id + "-1" - label = str(time.time_ns())[:-5] + "-bucket" - - bucket = client.object_storage.bucket_create( - cluster=cluster_region_name, label=label - ) - - yield bucket - - bucket.delete() - - -def test_list_obj_storage_bucket( - test_linode_client, test_object_storage_bucket -): - client = test_linode_client - - buckets = client.object_storage.buckets() - target_bucket = test_object_storage_bucket - - bucket_ids = [bucket.id for bucket in buckets] - - assert target_bucket.id in bucket_ids - assert isinstance(target_bucket, ObjectStorageBucket) - - -def test_bucket_access_modify(test_object_storage_bucket): - bucket = test_object_storage_bucket - - res = bucket.access_modify(ObjectStorageACL.PRIVATE, cors_enabled=True) - - assert res - - -def test_bucket_access_update(test_object_storage_bucket): - bucket = test_object_storage_bucket - res = bucket.access_update(ObjectStorageACL.PRIVATE, cors_enabled=True) - - assert res - - -def test_get_ssl_cert(test_object_storage_bucket): - bucket = test_object_storage_bucket - - res = bucket.ssl_cert().ssl - - assert res is False - - -def test_create_key_for_specific_bucket( - test_linode_client, test_object_storage_bucket -): - client = test_linode_client - bucket = test_object_storage_bucket - keys = client.object_storage.keys_create( - "restricted-keys", - bucket_access=client.object_storage.bucket_access( - bucket.cluster, bucket.id, "read_write" - ), - ) - - assert isinstance(keys, ObjectStorageKeys) - assert keys.bucket_access[0].bucket_name == bucket.id - assert keys.bucket_access[0].permissions == "read_write" - assert keys.bucket_access[0].cluster == bucket.cluster - - -def test_get_cluster(test_linode_client, test_object_storage_bucket): - client = test_linode_client - bucket = test_object_storage_bucket - - cluster = client.load(ObjectStorageCluster, bucket.cluster) - - assert "linodeobjects.com" in cluster.domain - assert cluster.id == bucket.cluster - assert "available" == cluster.status - - -def test_get_buckets_in_cluster(test_linode_client, test_object_storage_bucket): - client = test_linode_client - bucket = test_object_storage_bucket - - cluster = client.load(ObjectStorageCluster, bucket.cluster) - buckets = cluster.buckets_in_cluster() - bucket_ids = [bucket.id for bucket in buckets] - - assert bucket.id in bucket_ids From 6031fd410f4c849c537e0b9f9ddb65a0425f7931 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 11 Jul 2024 10:06:18 -0400 Subject: [PATCH 220/379] Revert "new: Add support for Linode Disk Encryption (#413)" (#435) --- linode_api4/groups/linode.py | 17 +------ linode_api4/objects/linode.py | 49 +----------------- linode_api4/objects/lke.py | 1 - test/fixtures/linode_instances.json | 4 -- test/fixtures/linode_instances_123_disks.json | 6 +-- ...inode_instances_123_disks_12345_clone.json | 3 +- .../lke_clusters_18881_nodes_123456.json | 2 +- .../lke_clusters_18881_pools_456.json | 3 +- test/integration/helpers.py | 6 +-- test/integration/models/linode/test_linode.py | 50 ++----------------- test/integration/models/lke/test_lke.py | 21 ++------ test/unit/objects/linode_test.py | 29 ++--------- test/unit/objects/lke_test.py | 6 +-- 13 files changed, 20 insertions(+), 177 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index c146ce46c..5f69d2b94 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,9 +1,7 @@ import base64 import os from collections.abc import Iterable -from typing import Optional, Union -from linode_api4 import InstanceDiskEncryptionType from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -130,15 +128,7 @@ def kernels(self, *filters): # create things def instance_create( - self, - ltype, - region, - image=None, - authorized_keys=None, - disk_encryption: Optional[ - Union[InstanceDiskEncryptionType, str] - ] = None, - **kwargs, + self, ltype, region, image=None, authorized_keys=None, **kwargs ): """ Creates a new Linode Instance. This function has several modes of operation: @@ -273,8 +263,6 @@ def instance_create( :type metadata: dict :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall - :param disk_encryption: The disk encryption policy for this Linode. - :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. :type interfaces: list[ConfigInterface] or list[dict[str, Any]] @@ -342,9 +330,6 @@ def instance_create( "authorized_keys": authorized_keys, } - if disk_encryption is not None: - params["disk_encryption"] = str(disk_encryption) - params.update(kwargs) result = self.client.post("/linode/instances", data=params) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index afcf6c2d5..d86ec1746 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -22,25 +22,12 @@ from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress -from linode_api4.objects.serializable import StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation -class InstanceDiskEncryptionType(StrEnum): - """ - InstanceDiskEncryptionType defines valid values for the - Instance(...).disk_encryption field. - - API Documentation: TODO - """ - - enabled = "enabled" - disabled = "disabled" - - class Backup(DerivedBase): """ A Backup of a Linode Instance. @@ -127,7 +114,6 @@ class Disk(DerivedBase): "filesystem": Property(), "updated": Property(is_datetime=True), "linode_id": Property(identifier=True), - "disk_encryption": Property(), } def duplicate(self): @@ -676,8 +662,6 @@ class Instance(Base): "host_uuid": Property(), "watchdog_enabled": Property(mutable=True), "has_user_data": Property(), - "disk_encryption": Property(), - "lke_cluster_id": Property(), } @property @@ -1407,16 +1391,7 @@ def ip_allocate(self, public=False): i = IPAddress(self._client, result["address"], result) return i - def rebuild( - self, - image, - root_pass=None, - authorized_keys=None, - disk_encryption: Optional[ - Union[InstanceDiskEncryptionType, str] - ] = None, - **kwargs, - ): + def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): """ Rebuilding an Instance deletes all existing Disks and Configs and deploys a new :any:`Image` to it. This can be used to reset an existing @@ -1434,8 +1409,6 @@ def rebuild( be a single key, or a path to a file containing the key. :type authorized_keys: list or str - :param disk_encryption: The disk encryption policy for this Linode. - :type disk_encryption: InstanceDiskEncryptionType or str :returns: The newly generated password, if one was not provided (otherwise True) @@ -1453,10 +1426,6 @@ def rebuild( "root_pass": root_pass, "authorized_keys": authorized_keys, } - - if disk_encryption is not None: - params["disk_encryption"] = str(disk_encryption) - params.update(kwargs) result = self._client.post( @@ -1786,22 +1755,6 @@ def stats(self): "{}/stats".format(Instance.api_endpoint), model=self ) - @property - def lke_cluster(self) -> Optional["LKECluster"]: - """ - Returns the LKE Cluster this Instance is a node of. - - :returns: The LKE Cluster this Instance is a node of. - :rtype: Optional[LKECluster] - """ - - # Local import to prevent circular dependency - from linode_api4.objects.lke import ( # pylint: disable=import-outside-toplevel - LKECluster, - ) - - return LKECluster(self._client, self.lke_cluster_id) - def stats_for(self, dt): """ Returns stats for the month containing the given datetime diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 14f7f28db..4d3ec5a16 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -132,7 +132,6 @@ class LKENodePool(DerivedBase): "cluster_id": Property(identifier=True), "type": Property(slug_relationship=Type), "disks": Property(), - "disk_encryption": Property(), "count": Property(mutable=True), "nodes": Property( volatile=True diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 130d44285..651fc56c1 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -41,8 +41,6 @@ "tags": ["something"], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, - "disk_encryption": "disabled", - "lke_cluster_id": null, "placement_group": { "id": 123, "label": "test", @@ -88,8 +86,6 @@ "tags": [], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": false, - "disk_encryption": "enabled", - "lke_cluster_id": 18881, "placement_group": null } ] diff --git a/test/fixtures/linode_instances_123_disks.json b/test/fixtures/linode_instances_123_disks.json index ddfe7f313..eca5079e5 100644 --- a/test/fixtures/linode_instances_123_disks.json +++ b/test/fixtures/linode_instances_123_disks.json @@ -10,8 +10,7 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00", - "disk_encryption": "disabled" + "created": "2017-01-01T00:00:00" }, { "size": 512, @@ -20,8 +19,7 @@ "id": 12346, "updated": "2017-01-01T00:00:00", "label": "512 MB Swap Image", - "created": "2017-01-01T00:00:00", - "disk_encryption": "disabled" + "created": "2017-01-01T00:00:00" } ] } diff --git a/test/fixtures/linode_instances_123_disks_12345_clone.json b/test/fixtures/linode_instances_123_disks_12345_clone.json index 899833e56..2d378edca 100644 --- a/test/fixtures/linode_instances_123_disks_12345_clone.json +++ b/test/fixtures/linode_instances_123_disks_12345_clone.json @@ -5,7 +5,6 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00", - "disk_encryption": "disabled" + "created": "2017-01-01T00:00:00" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_nodes_123456.json b/test/fixtures/lke_clusters_18881_nodes_123456.json index 646b62f5d..311ef3878 100644 --- a/test/fixtures/lke_clusters_18881_nodes_123456.json +++ b/test/fixtures/lke_clusters_18881_nodes_123456.json @@ -1,5 +1,5 @@ { "id": "123456", - "instance_id": 456, + "instance_id": 123458, "status": "ready" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index 225023d5d..ec6b570ac 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -23,6 +23,5 @@ "example tag", "another example" ], - "type": "g6-standard-4", - "disk_encryption": "enabled" + "type": "g6-standard-4" } \ No newline at end of file diff --git a/test/integration/helpers.py b/test/integration/helpers.py index e0aab06c4..5e9d1c441 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -79,14 +79,12 @@ def wait_for_condition( # Retry function to help in case of requests sending too quickly before instance is ready -def retry_sending_request( - retries: int, condition: Callable, *args, **kwargs -) -> object: +def retry_sending_request(retries: int, condition: Callable, *args) -> object: curr_t = 0 while curr_t < retries: try: curr_t += 1 - res = condition(*args, **kwargs) + res = condition(*args) return res except ApiError: if curr_t >= retries: diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 01f3aaa16..02b6220a3 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,5 +1,4 @@ import time -from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, retry_sending_request, @@ -19,7 +18,7 @@ Instance, Type, ) -from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType +from linode_api4.objects.linode import MigrationType @pytest.fixture(scope="session") @@ -143,30 +142,6 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() -@pytest.fixture(scope="function") -def linode_with_disk_encryption(test_linode_client, request): - client = test_linode_client - - target_region = get_region(client, {"Disk Encryption"}) - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp - - disk_encryption = request.param - - linode_instance, password = client.linode.instance_create( - "g6-nanode-1", - target_region, - image="linode/ubuntu23.04", - label=label, - booted=False, - disk_encryption=disk_encryption, - ) - - yield linode_instance - - linode_instance.delete() - - # Test helper def get_status(linode: Instance, status: str): return linode.status == status @@ -195,7 +170,8 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - chosen_region = get_region(client, {"Disk Encryption"}) + available_regions = client.regions() + chosen_region = available_regions[4] label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( @@ -204,18 +180,12 @@ def test_linode_rebuild(test_linode_client): wait_for_condition(10, 100, get_status, linode, "running") - retry_sending_request( - 3, - linode.rebuild, - "linode/debian10", - disk_encryption=InstanceDiskEncryptionType.disabled, - ) + retry_sending_request(3, linode.rebuild, "linode/debian10") wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" assert linode.image.id == "linode/debian10" - assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -418,18 +388,6 @@ def test_linode_volumes(linode_with_volume_firewall): assert "test" in volumes[0].label -@pytest.mark.parametrize( - "linode_with_disk_encryption", ["disabled"], indirect=True -) -def test_linode_with_disk_encryption_disabled(linode_with_disk_encryption): - linode = linode_with_disk_encryption - - assert linode.disk_encryption == InstanceDiskEncryptionType.disabled - assert ( - linode.disks[0].disk_encryption == InstanceDiskEncryptionType.disabled - ) - - def wait_for_disk_status(disk: Disk, timeout): start_time = time.time() while True: diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index ce6700b80..2e74c8205 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -1,17 +1,14 @@ import base64 import re -from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, send_request_when_resource_available, wait_for_condition, ) -from typing import Any, Dict import pytest from linode_api4 import ( - InstanceDiskEncryptionType, LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, @@ -24,7 +21,7 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = get_region(test_linode_client, {"Disk Encryption", "Kubernetes"}) + region = test_linode_client.regions().first() node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -41,7 +38,7 @@ def lke_cluster(test_linode_client): def lke_cluster_with_acl(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = get_region(test_linode_client, {"Kubernetes"}) + region = test_linode_client.regions().first() node_pools = test_linode_client.lke.node_pool(node_type, 1) label = get_test_label() + "_cluster" @@ -84,21 +81,9 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): cluster = lke_cluster - wait_for_condition( - 10, - 500, - get_node_status, - cluster, - "ready", - ) - pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) - def _to_comparable(p: LKENodePool) -> Dict[str, Any]: - return {k: v for k, v in p._raw_json.items() if k not in {"nodes"}} - - assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) - assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + assert cluster.pools[0].id == pool.id def test_cluster_dashboard_url_view(lke_cluster): diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index e24f1107c..8b03cbe7c 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,11 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import ( - InstanceDiskEncryptionType, - InstancePlacementGroupAssignment, - NetworkInterface, -) +from linode_api4 import InstancePlacementGroupAssignment, NetworkInterface from linode_api4.objects import ( Config, ConfigInterface, @@ -40,10 +36,6 @@ def test_get_linode(self): linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8" ) self.assertEqual(linode.watchdog_enabled, True) - self.assertEqual( - linode.disk_encryption, InstanceDiskEncryptionType.disabled - ) - self.assertEqual(linode.lke_cluster_id, None) json = linode._raw_json self.assertIsNotNone(json) @@ -80,10 +72,7 @@ def test_rebuild(self): linode = Instance(self.client, 123) with self.mock_post("/linode/instances/123") as m: - pw = linode.rebuild( - "linode/debian9", - disk_encryption=InstanceDiskEncryptionType.enabled, - ) + pw = linode.rebuild("linode/debian9") self.assertIsNotNone(pw) self.assertTrue(isinstance(pw, str)) @@ -95,7 +84,6 @@ def test_rebuild(self): { "image": "linode/debian9", "root_pass": pw, - "disk_encryption": "enabled", }, ) @@ -318,15 +306,6 @@ def test_transfer_year_month(self): m.call_url, "/linode/instances/123/transfer/2023/4" ) - def test_lke_cluster(self): - """ - Tests that you can grab the parent LKE cluster from an instance node - """ - linode = Instance(self.client, 456) - - assert linode.lke_cluster_id == 18881 - assert linode.lke_cluster.id == linode.lke_cluster_id - def test_duplicate(self): """ Tests that you can submit a correct disk clone api request @@ -339,8 +318,6 @@ def test_duplicate(self): m.call_url, "/linode/instances/123/disks/12345/clone" ) - assert disk.disk_encryption == InstanceDiskEncryptionType.disabled - def test_disk_password(self): """ Tests that you can submit a correct disk password reset api request @@ -416,6 +393,7 @@ def test_create_disk(self): image="linode/debian10", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") + print(m.call_data) self.assertEqual( m.call_data, { @@ -429,7 +407,6 @@ def test_create_disk(self): ) assert disk.id == 12345 - assert disk.disk_encryption == InstanceDiskEncryptionType.disabled def test_instance_create_with_user_data(self): """ diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index f39fb84ae..a44db97ef 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,7 +1,6 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import InstanceDiskEncryptionType from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -48,9 +47,6 @@ def test_get_pool(self): self.assertEqual(pool.id, 456) self.assertEqual(pool.cluster_id, 18881) self.assertEqual(pool.type.id, "g6-standard-4") - self.assertEqual( - pool.disk_encryption, InstanceDiskEncryptionType.enabled - ) self.assertIsNotNone(pool.disks) self.assertIsNotNone(pool.nodes) self.assertIsNotNone(pool.autoscaler) @@ -88,7 +84,7 @@ def test_node_view(self): self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/123456") self.assertIsNotNone(node) self.assertEqual(node.id, "123456") - self.assertEqual(node.instance_id, 456) + self.assertEqual(node.instance_id, 123458) self.assertEqual(node.status, "ready") def test_node_delete(self): From ca6810635235959da2945a9df4f39522dda21f51 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Thu, 11 Jul 2024 11:39:21 -0400 Subject: [PATCH 221/379] Re-apply "new: Add support for Linode Disk Encryption (#413)" This reverts commit 6031fd410f4c849c537e0b9f9ddb65a0425f7931. --- linode_api4/groups/linode.py | 17 ++++++- linode_api4/objects/linode.py | 49 +++++++++++++++++- linode_api4/objects/lke.py | 1 + test/fixtures/linode_instances.json | 4 ++ test/fixtures/linode_instances_123_disks.json | 6 ++- ...inode_instances_123_disks_12345_clone.json | 3 +- .../lke_clusters_18881_nodes_123456.json | 2 +- .../lke_clusters_18881_pools_456.json | 3 +- test/integration/helpers.py | 6 ++- test/integration/models/linode/test_linode.py | 50 +++++++++++++++++-- test/integration/models/lke/test_lke.py | 21 ++++++-- test/unit/objects/linode_test.py | 29 +++++++++-- test/unit/objects/lke_test.py | 6 ++- 13 files changed, 177 insertions(+), 20 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 5f69d2b94..c146ce46c 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,7 +1,9 @@ import base64 import os from collections.abc import Iterable +from typing import Optional, Union +from linode_api4 import InstanceDiskEncryptionType from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group @@ -128,7 +130,15 @@ def kernels(self, *filters): # create things def instance_create( - self, ltype, region, image=None, authorized_keys=None, **kwargs + self, + ltype, + region, + image=None, + authorized_keys=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, + **kwargs, ): """ Creates a new Linode Instance. This function has several modes of operation: @@ -263,6 +273,8 @@ def instance_create( :type metadata: dict :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall + :param disk_encryption: The disk encryption policy for this Linode. + :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. :type interfaces: list[ConfigInterface] or list[dict[str, Any]] @@ -330,6 +342,9 @@ def instance_create( "authorized_keys": authorized_keys, } + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + params.update(kwargs) result = self.client.post("/linode/instances", data=params) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index d86ec1746..afcf6c2d5 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -22,12 +22,25 @@ from linode_api4.objects.base import MappedObject from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress +from linode_api4.objects.serializable import StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation +class InstanceDiskEncryptionType(StrEnum): + """ + InstanceDiskEncryptionType defines valid values for the + Instance(...).disk_encryption field. + + API Documentation: TODO + """ + + enabled = "enabled" + disabled = "disabled" + + class Backup(DerivedBase): """ A Backup of a Linode Instance. @@ -114,6 +127,7 @@ class Disk(DerivedBase): "filesystem": Property(), "updated": Property(is_datetime=True), "linode_id": Property(identifier=True), + "disk_encryption": Property(), } def duplicate(self): @@ -662,6 +676,8 @@ class Instance(Base): "host_uuid": Property(), "watchdog_enabled": Property(mutable=True), "has_user_data": Property(), + "disk_encryption": Property(), + "lke_cluster_id": Property(), } @property @@ -1391,7 +1407,16 @@ def ip_allocate(self, public=False): i = IPAddress(self._client, result["address"], result) return i - def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): + def rebuild( + self, + image, + root_pass=None, + authorized_keys=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, + **kwargs, + ): """ Rebuilding an Instance deletes all existing Disks and Configs and deploys a new :any:`Image` to it. This can be used to reset an existing @@ -1409,6 +1434,8 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): be a single key, or a path to a file containing the key. :type authorized_keys: list or str + :param disk_encryption: The disk encryption policy for this Linode. + :type disk_encryption: InstanceDiskEncryptionType or str :returns: The newly generated password, if one was not provided (otherwise True) @@ -1426,6 +1453,10 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): "root_pass": root_pass, "authorized_keys": authorized_keys, } + + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + params.update(kwargs) result = self._client.post( @@ -1755,6 +1786,22 @@ def stats(self): "{}/stats".format(Instance.api_endpoint), model=self ) + @property + def lke_cluster(self) -> Optional["LKECluster"]: + """ + Returns the LKE Cluster this Instance is a node of. + + :returns: The LKE Cluster this Instance is a node of. + :rtype: Optional[LKECluster] + """ + + # Local import to prevent circular dependency + from linode_api4.objects.lke import ( # pylint: disable=import-outside-toplevel + LKECluster, + ) + + return LKECluster(self._client, self.lke_cluster_id) + def stats_for(self, dt): """ Returns stats for the month containing the given datetime diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 4d3ec5a16..14f7f28db 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -132,6 +132,7 @@ class LKENodePool(DerivedBase): "cluster_id": Property(identifier=True), "type": Property(slug_relationship=Type), "disks": Property(), + "disk_encryption": Property(), "count": Property(mutable=True), "nodes": Property( volatile=True diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 651fc56c1..130d44285 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -41,6 +41,8 @@ "tags": ["something"], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, "placement_group": { "id": 123, "label": "test", @@ -86,6 +88,8 @@ "tags": [], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": false, + "disk_encryption": "enabled", + "lke_cluster_id": 18881, "placement_group": null } ] diff --git a/test/fixtures/linode_instances_123_disks.json b/test/fixtures/linode_instances_123_disks.json index eca5079e5..ddfe7f313 100644 --- a/test/fixtures/linode_instances_123_disks.json +++ b/test/fixtures/linode_instances_123_disks.json @@ -10,7 +10,8 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" }, { "size": 512, @@ -19,7 +20,8 @@ "id": 12346, "updated": "2017-01-01T00:00:00", "label": "512 MB Swap Image", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" } ] } diff --git a/test/fixtures/linode_instances_123_disks_12345_clone.json b/test/fixtures/linode_instances_123_disks_12345_clone.json index 2d378edca..899833e56 100644 --- a/test/fixtures/linode_instances_123_disks_12345_clone.json +++ b/test/fixtures/linode_instances_123_disks_12345_clone.json @@ -5,6 +5,7 @@ "id": 12345, "updated": "2017-01-01T00:00:00", "label": "Ubuntu 17.04 Disk", - "created": "2017-01-01T00:00:00" + "created": "2017-01-01T00:00:00", + "disk_encryption": "disabled" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_nodes_123456.json b/test/fixtures/lke_clusters_18881_nodes_123456.json index 311ef3878..646b62f5d 100644 --- a/test/fixtures/lke_clusters_18881_nodes_123456.json +++ b/test/fixtures/lke_clusters_18881_nodes_123456.json @@ -1,5 +1,5 @@ { "id": "123456", - "instance_id": 123458, + "instance_id": 456, "status": "ready" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index ec6b570ac..225023d5d 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -23,5 +23,6 @@ "example tag", "another example" ], - "type": "g6-standard-4" + "type": "g6-standard-4", + "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 5e9d1c441..e0aab06c4 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -79,12 +79,14 @@ def wait_for_condition( # Retry function to help in case of requests sending too quickly before instance is ready -def retry_sending_request(retries: int, condition: Callable, *args) -> object: +def retry_sending_request( + retries: int, condition: Callable, *args, **kwargs +) -> object: curr_t = 0 while curr_t < retries: try: curr_t += 1 - res = condition(*args) + res = condition(*args, **kwargs) return res except ApiError: if curr_t >= retries: diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 02b6220a3..01f3aaa16 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,4 +1,5 @@ import time +from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, retry_sending_request, @@ -18,7 +19,7 @@ Instance, Type, ) -from linode_api4.objects.linode import MigrationType +from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @pytest.fixture(scope="session") @@ -142,6 +143,30 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_with_disk_encryption(test_linode_client, request): + client = test_linode_client + + target_region = get_region(client, {"Disk Encryption"}) + timestamp = str(time.time_ns()) + label = "TestSDK-" + timestamp + + disk_encryption = request.param + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + target_region, + image="linode/ubuntu23.04", + label=label, + booted=False, + disk_encryption=disk_encryption, + ) + + yield linode_instance + + linode_instance.delete() + + # Test helper def get_status(linode: Instance, status: str): return linode.status == status @@ -170,8 +195,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Disk Encryption"}) label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( @@ -180,12 +204,18 @@ def test_linode_rebuild(test_linode_client): wait_for_condition(10, 100, get_status, linode, "running") - retry_sending_request(3, linode.rebuild, "linode/debian10") + retry_sending_request( + 3, + linode.rebuild, + "linode/debian10", + disk_encryption=InstanceDiskEncryptionType.disabled, + ) wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" assert linode.image.id == "linode/debian10" + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -388,6 +418,18 @@ def test_linode_volumes(linode_with_volume_firewall): assert "test" in volumes[0].label +@pytest.mark.parametrize( + "linode_with_disk_encryption", ["disabled"], indirect=True +) +def test_linode_with_disk_encryption_disabled(linode_with_disk_encryption): + linode = linode_with_disk_encryption + + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled + assert ( + linode.disks[0].disk_encryption == InstanceDiskEncryptionType.disabled + ) + + def wait_for_disk_status(disk: Disk, timeout): start_time = time.time() while True: diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 2e74c8205..ce6700b80 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -1,14 +1,17 @@ import base64 import re +from test.integration.conftest import get_region from test.integration.helpers import ( get_test_label, send_request_when_resource_available, wait_for_condition, ) +from typing import Any, Dict import pytest from linode_api4 import ( + InstanceDiskEncryptionType, LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, @@ -21,7 +24,7 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = test_linode_client.regions().first() + region = get_region(test_linode_client, {"Disk Encryption", "Kubernetes"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -38,7 +41,7 @@ def lke_cluster(test_linode_client): def lke_cluster_with_acl(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = test_linode_client.regions().first() + region = get_region(test_linode_client, {"Kubernetes"}) node_pools = test_linode_client.lke.node_pool(node_type, 1) label = get_test_label() + "_cluster" @@ -81,9 +84,21 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): def test_get_lke_pool(test_linode_client, lke_cluster): cluster = lke_cluster + wait_for_condition( + 10, + 500, + get_node_status, + cluster, + "ready", + ) + pool = test_linode_client.load(LKENodePool, cluster.pools[0].id, cluster.id) - assert cluster.pools[0].id == pool.id + def _to_comparable(p: LKENodePool) -> Dict[str, Any]: + return {k: v for k, v in p._raw_json.items() if k not in {"nodes"}} + + assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled def test_cluster_dashboard_url_view(lke_cluster): diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 8b03cbe7c..e24f1107c 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,11 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import InstancePlacementGroupAssignment, NetworkInterface +from linode_api4 import ( + InstanceDiskEncryptionType, + InstancePlacementGroupAssignment, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, @@ -36,6 +40,10 @@ def test_get_linode(self): linode.host_uuid, "3a3ddd59d9a78bb8de041391075df44de62bfec8" ) self.assertEqual(linode.watchdog_enabled, True) + self.assertEqual( + linode.disk_encryption, InstanceDiskEncryptionType.disabled + ) + self.assertEqual(linode.lke_cluster_id, None) json = linode._raw_json self.assertIsNotNone(json) @@ -72,7 +80,10 @@ def test_rebuild(self): linode = Instance(self.client, 123) with self.mock_post("/linode/instances/123") as m: - pw = linode.rebuild("linode/debian9") + pw = linode.rebuild( + "linode/debian9", + disk_encryption=InstanceDiskEncryptionType.enabled, + ) self.assertIsNotNone(pw) self.assertTrue(isinstance(pw, str)) @@ -84,6 +95,7 @@ def test_rebuild(self): { "image": "linode/debian9", "root_pass": pw, + "disk_encryption": "enabled", }, ) @@ -306,6 +318,15 @@ def test_transfer_year_month(self): m.call_url, "/linode/instances/123/transfer/2023/4" ) + def test_lke_cluster(self): + """ + Tests that you can grab the parent LKE cluster from an instance node + """ + linode = Instance(self.client, 456) + + assert linode.lke_cluster_id == 18881 + assert linode.lke_cluster.id == linode.lke_cluster_id + def test_duplicate(self): """ Tests that you can submit a correct disk clone api request @@ -318,6 +339,8 @@ def test_duplicate(self): m.call_url, "/linode/instances/123/disks/12345/clone" ) + assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_disk_password(self): """ Tests that you can submit a correct disk password reset api request @@ -393,7 +416,6 @@ def test_create_disk(self): image="linode/debian10", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") - print(m.call_data) self.assertEqual( m.call_data, { @@ -407,6 +429,7 @@ def test_create_disk(self): ) assert disk.id == 12345 + assert disk.disk_encryption == InstanceDiskEncryptionType.disabled def test_instance_create_with_user_data(self): """ diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index a44db97ef..f39fb84ae 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,6 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import InstanceDiskEncryptionType from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -47,6 +48,9 @@ def test_get_pool(self): self.assertEqual(pool.id, 456) self.assertEqual(pool.cluster_id, 18881) self.assertEqual(pool.type.id, "g6-standard-4") + self.assertEqual( + pool.disk_encryption, InstanceDiskEncryptionType.enabled + ) self.assertIsNotNone(pool.disks) self.assertIsNotNone(pool.nodes) self.assertIsNotNone(pool.autoscaler) @@ -84,7 +88,7 @@ def test_node_view(self): self.assertEqual(m.call_url, "/lke/clusters/18881/nodes/123456") self.assertIsNotNone(node) self.assertEqual(node.id, "123456") - self.assertEqual(node.instance_id, 123458) + self.assertEqual(node.instance_id, 456) self.assertEqual(node.status, "ready") def test_node_delete(self): From f8308035b109ceda59f0795872e04df6b2436553 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Mon, 15 Jul 2024 10:12:53 -0700 Subject: [PATCH 222/379] add submodule checkout in release-cross-repo-test.yml (#438) --- .github/workflows/release-cross-repo-test.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 0850ed9ea..8708c3422 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -14,6 +14,9 @@ jobs: steps: - name: Checkout linode_api4 repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' - name: update packages run: sudo apt-get update -y @@ -26,11 +29,13 @@ jobs: with: python-version: '3.10' - - name: checkout repo - uses: actions/checkout@v3 + - name: Checkout ansible repo + uses: actions/checkout@v4 with: repository: linode/ansible_linode path: .ansible/collections/ansible_collections/linode/cloud + fetch-depth: 0 + submodules: 'recursive' - name: install dependencies run: | From 35b4ae04b348a90f8df3dffc6b5b12d1348f92ac Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:58:43 -0400 Subject: [PATCH 223/379] PG affinity_type, is_strict -> placement_group_type, placement_group_policy (#437) --- linode_api4/groups/placement.py | 22 +++++++++++++--------- linode_api4/objects/placement.py | 18 +++++++++++++----- test/fixtures/linode_instances.json | 4 ++-- test/fixtures/placement_groups.json | 4 ++-- test/fixtures/placement_groups_123.json | 4 ++-- test/integration/conftest.py | 5 +++-- test/unit/objects/linode_test.py | 4 ++-- test/unit/objects/placement_test.py | 17 +++++++++-------- 8 files changed, 46 insertions(+), 32 deletions(-) diff --git a/linode_api4/groups/placement.py b/linode_api4/groups/placement.py index 90456fd17..e56970346 100644 --- a/linode_api4/groups/placement.py +++ b/linode_api4/groups/placement.py @@ -2,7 +2,11 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects.placement import PlacementGroup +from linode_api4.objects.placement import ( + PlacementGroup, + PlacementGroupPolicy, + PlacementGroupType, +) from linode_api4.objects.region import Region @@ -31,8 +35,8 @@ def group_create( self, label: str, region: Union[Region, str], - affinity_type: str, - is_strict: bool = False, + placement_group_type: PlacementGroupType, + placement_group_policy: PlacementGroupPolicy, **kwargs, ) -> PlacementGroup: """ @@ -44,10 +48,10 @@ def group_create( :type label: str :param region: The region where the placement group will be created. Can be either a Region object or a string representing the region ID. :type region: Union[Region, str] - :param affinity_type: The affinity type of the placement group. - :type affinity_type: PlacementGroupAffinityType - :param is_strict: Whether the placement group is strict (defaults to False). - :type is_strict: bool + :param placement_group_type: The type of the placement group. + :type placement_group_type: PlacementGroupType + :param placement_group_policy: The policy for assignments to this placement group. + :type placement_group_policy: PlacementGroupPolicy :returns: The new Placement Group. :rtype: PlacementGroup @@ -55,8 +59,8 @@ def group_create( params = { "label": label, "region": region.id if isinstance(region, Region) else region, - "affinity_type": affinity_type, - "is_strict": is_strict, + "placement_group_type": placement_group_type, + "placement_group_policy": placement_group_policy, } params.update(kwargs) diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py index eb5808eee..616c9061f 100644 --- a/linode_api4/objects/placement.py +++ b/linode_api4/objects/placement.py @@ -7,15 +7,23 @@ from linode_api4.objects.serializable import JSONObject, StrEnum -class PlacementGroupAffinityType(StrEnum): +class PlacementGroupType(StrEnum): """ - An enum class that represents the available affinity policies for Linodes - in a Placement Group. + An enum class that represents the available types of a Placement Group. """ anti_affinity_local = "anti_affinity:local" +class PlacementGroupPolicy(StrEnum): + """ + An enum class that represents the policy for Linode assignments to a Placement Group. + """ + + strict = "strict" + flexible = "flexible" + + @dataclass class PlacementGroupMember(JSONObject): """ @@ -42,9 +50,9 @@ class PlacementGroup(Base): "id": Property(identifier=True), "label": Property(mutable=True), "region": Property(slug_relationship=Region), - "affinity_type": Property(), + "placement_group_type": Property(), + "placement_group_policy": Property(), "is_compliant": Property(), - "is_strict": Property(), "members": Property(json_object=PlacementGroupMember), } diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 651fc56c1..a991c1c4d 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -44,8 +44,8 @@ "placement_group": { "id": 123, "label": "test", - "affinity_type": "anti_affinity:local", - "is_strict": true + "placement_group_type": "anti_affinity:local", + "placement_group_policy": "strict" } }, { diff --git a/test/fixtures/placement_groups.json b/test/fixtures/placement_groups.json index f518e838d..758fc8521 100644 --- a/test/fixtures/placement_groups.json +++ b/test/fixtures/placement_groups.json @@ -4,8 +4,8 @@ "id": 123, "label": "test", "region": "eu-west", - "affinity_type": "anti_affinity:local", - "is_strict": true, + "placement_group_type": "anti_affinity:local", + "placement_group_policy": "strict", "is_compliant": true, "members": [ { diff --git a/test/fixtures/placement_groups_123.json b/test/fixtures/placement_groups_123.json index 5262bebe0..453e9fd5f 100644 --- a/test/fixtures/placement_groups_123.json +++ b/test/fixtures/placement_groups_123.json @@ -2,8 +2,8 @@ "id": 123, "label": "test", "region": "eu-west", - "affinity_type": "anti_affinity:local", - "is_strict": true, + "placement_group_type": "anti_affinity:local", + "placement_group_policy": "strict", "is_compliant": true, "members": [ { diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3638bd57d..9ef80e903 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -8,7 +8,7 @@ import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import ApiError, PlacementGroupAffinityType +from linode_api4 import ApiError, PlacementGroupPolicy, PlacementGroupType from linode_api4.linode_client import LinodeClient from linode_api4.objects import Region @@ -465,7 +465,8 @@ def create_placement_group(test_linode_client): pg = client.placement.group_create( "pythonsdk-" + timestamp, get_region(test_linode_client, {"Placement Group"}), - PlacementGroupAffinityType.anti_affinity_local, + PlacementGroupType.anti_affinity_local, + PlacementGroupPolicy.flexible, ) yield pg diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 8b03cbe7c..029392ab0 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -486,7 +486,7 @@ def test_get_placement_group(self): assert pg.id == 123 assert pg.label == "test" - assert pg.affinity_type == "anti_affinity:local" + assert pg.placement_group_type == "anti_affinity:local" # Invalidate the instance and try again # This makes sure the implicit refresh/cache logic works @@ -497,7 +497,7 @@ def test_get_placement_group(self): assert pg.id == 123 assert pg.label == "test" - assert pg.affinity_type == "anti_affinity:local" + assert pg.placement_group_type == "anti_affinity:local" def test_create_with_placement_group(self): """ diff --git a/test/unit/objects/placement_test.py b/test/unit/objects/placement_test.py index f3809d898..71d171644 100644 --- a/test/unit/objects/placement_test.py +++ b/test/unit/objects/placement_test.py @@ -1,9 +1,10 @@ from test.unit.base import ClientBaseCase +from linode_api4 import PlacementGroupPolicy from linode_api4.objects import ( PlacementGroup, - PlacementGroupAffinityType, PlacementGroupMember, + PlacementGroupType, ) @@ -42,8 +43,8 @@ def test_create_pg(self): pg = self.client.placement.group_create( "test", "eu-west", - PlacementGroupAffinityType.anti_affinity_local, - is_strict=True, + PlacementGroupType.anti_affinity_local, + PlacementGroupPolicy.strict, ) assert m.call_url == "/placement/groups" @@ -53,10 +54,10 @@ def test_create_pg(self): { "label": "test", "region": "eu-west", - "affinity_type": str( - PlacementGroupAffinityType.anti_affinity_local + "placement_group_type": str( + PlacementGroupType.anti_affinity_local ), - "is_strict": True, + "placement_group_policy": PlacementGroupPolicy.strict, }, ) @@ -109,8 +110,8 @@ def validate_pg_123(self, pg: PlacementGroup): assert pg.id == 123 assert pg.label == "test" assert pg.region.id == "eu-west" - assert pg.affinity_type == "anti_affinity:local" - assert pg.is_strict + assert pg.placement_group_type == "anti_affinity:local" + assert pg.placement_group_policy == "strict" assert pg.is_compliant assert pg.members[0] == PlacementGroupMember( linode_id=123, is_compliant=True From 8341dcea61514eadb2f0da51463e0bc4857251de Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:03:10 -0400 Subject: [PATCH 224/379] Add project label (#440) * Add project label * Update release.yml --- .github/labels.yml | 3 +++ .github/release.yml | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/labels.yml b/.github/labels.yml index 2a28fc812..83989042c 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -2,6 +2,9 @@ - name: new-feature description: for new features in the changelog. color: 225fee +- name: project + description: for new projects in the changelog. + color: 46BAF0 - name: improvement description: for improvements in existing functionality in the changelog. color: 22ee47 diff --git a/.github/release.yml b/.github/release.yml index 8417f9fb9..a2318fa64 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -3,7 +3,10 @@ changelog: labels: - ignore-for-release categories: - - title: ⚠️ Breaking Change + - title: 📋 New Project + labels: + - project + - title: ⚠️ Breaking Change labels: - breaking-change - title: 🐛 Bug Fixes @@ -18,7 +21,7 @@ changelog: - title: 🧪 Testing Improvements labels: - testing - - title: ⚙️ Repo/CI Improvements + - title: ⚙️ Repo/CI Improvements labels: - repo-ci-improvement - title: 📖 Documentation From 72481ad6046e993ab6e707e8caae15de58432f75 Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Tue, 23 Jul 2024 11:15:32 -0400 Subject: [PATCH 225/379] Update api doc urls to point to techdocs (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Updates any api doc urls to point to https://techdocs.akamai.com/linode-api/reference/api --- README.rst | 2 +- docs/index.rst | 4 +- linode_api4/groups/account.py | 46 ++++++++--------- linode_api4/groups/beta.py | 2 +- linode_api4/groups/database.py | 14 +++--- linode_api4/groups/domain.py | 4 +- linode_api4/groups/image.py | 8 +-- linode_api4/groups/linode.py | 12 ++--- linode_api4/groups/lke.py | 6 +-- linode_api4/groups/longview.py | 6 +-- linode_api4/groups/networking.py | 22 ++++---- linode_api4/groups/nodebalancer.py | 4 +- linode_api4/groups/object_storage.py | 18 +++---- linode_api4/groups/polling.py | 6 +-- linode_api4/groups/profile.py | 24 ++++----- linode_api4/groups/region.py | 4 +- linode_api4/groups/support.py | 4 +- linode_api4/groups/tag.py | 4 +- linode_api4/groups/volume.py | 4 +- linode_api4/objects/account.py | 46 ++++++++--------- linode_api4/objects/beta.py | 2 +- linode_api4/objects/database.py | 36 +++++++------- linode_api4/objects/domain.py | 12 ++--- linode_api4/objects/image.py | 2 +- linode_api4/objects/linode.py | 72 +++++++++++++-------------- linode_api4/objects/lke.py | 30 +++++------ linode_api4/objects/longview.py | 6 +-- linode_api4/objects/networking.py | 16 +++--- linode_api4/objects/nodebalancer.py | 16 +++--- linode_api4/objects/object_storage.py | 24 ++++----- linode_api4/objects/profile.py | 20 ++++---- linode_api4/objects/region.py | 4 +- linode_api4/objects/support.py | 10 ++-- linode_api4/objects/tag.py | 4 +- linode_api4/objects/volume.py | 10 ++-- pyproject.toml | 2 +- 36 files changed, 253 insertions(+), 253 deletions(-) diff --git a/README.rst b/README.rst index bbbfeb31a..1e6b310f4 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ linode_api4 The official python library for the `Linode API v4`_ in python. -.. _Linode API v4: https://developers.linode.com/api/v4/ +.. _Linode API v4: https://techdocs.akamai.com/linode-api/reference/api .. image:: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests :target: https://img.shields.io/github/actions/workflow/status/linode/linode_api4-python/main.yml?label=tests diff --git a/docs/index.rst b/docs/index.rst index 828e7e751..1faf5dfa8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,11 +2,11 @@ linode_api4 =========== This is the documentation for the official Python bindings of the Linode -API v4. For API documentation, see `developers.linode.com`_. +API v4. For API documentation, see `techdocs.akamai.com`_. This library can be used to interact with all features of the Linode API. -.. _developers.linode.com: https://developers.linode.com/api/v4 +.. _techdocs.akamai.com: https://techdocs.akamai.com/linode-api/reference/api Installation ------------ diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 21540ea7f..88f53ed09 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -34,7 +34,7 @@ def __call__(self): account = client.account() - API Documentation: https://www.linode.com/docs/api/account/#account-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account :returns: Returns the acting user's account information. :rtype: Account @@ -52,7 +52,7 @@ def events(self, *filters): """ Lists events on the current account matching the given filters. - API Documentation: https://www.linode.com/docs/api/account/#events-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-events :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -69,7 +69,7 @@ def events_mark_seen(self, event): Marks event as the last event we have seen. If event is an int, it is treated as an event_id, otherwise it should be an event object whose id will be used. - API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-event-seen :param event: The Linode event to mark as seen. :type event: Event or int @@ -85,7 +85,7 @@ def settings(self): Returns the account settings data for this acocunt. This is not a listing endpoint. - API Documentation: https://www.linode.com/docs/api/account/#account-settings-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-settings :returns: The account settings data for this account. :rtype: AccountSettings @@ -105,7 +105,7 @@ def invoices(self): """ Returns Invoices issued to this account. - API Documentation: https://www.linode.com/docs/api/account/#invoices-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-invoices :param filters: Any number of filters to apply to this query. @@ -118,7 +118,7 @@ def payments(self): """ Returns a list of Payments made on this account. - API Documentation: https://www.linode.com/docs/api/account/#payments-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-payments :returns: A list of payments made on this account. :rtype: PaginatedList of Payment @@ -129,7 +129,7 @@ def oauth_clients(self, *filters): """ Returns the OAuth Clients associated with this account. - API Documentation: https://www.linode.com/docs/api/account/#oauth-clients-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-clients :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -144,7 +144,7 @@ def oauth_client_create(self, name, redirect_uri, **kwargs): """ Creates a new OAuth client. - API Documentation: https://www.linode.com/docs/api/account/#oauth-client-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-client :param name: The name of this application. :type name: str @@ -174,7 +174,7 @@ def users(self, *filters): """ Returns a list of users on this account. - API Documentation: https://www.linode.com/docs/api/account/#users-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-users :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -189,7 +189,7 @@ def logins(self): """ Returns a collection of successful logins for all users on the account during the last 90 days. - API Documentation: https://www.linode.com/docs/api/account/#user-logins-list-all + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-logins :returns: A list of Logins on this account. :rtype: PaginatedList of Login @@ -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://www.linode.com/docs/api/account/#user-logins-list-all + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-logins :returns: A list of Maintenance objects on this account. :rtype: List of Maintenance objects as MappedObjects @@ -217,7 +217,7 @@ def payment_methods(self): """ Returns a list of Payment Methods for this Account. - API Documentation: https://www.linode.com/docs/api/account/#payment-methods-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-payment-methods :returns: A list of Payment Methods on this account. :rtype: PaginatedList of PaymentMethod @@ -229,7 +229,7 @@ def add_payment_method(self, data, is_default, type): """ Adds a Payment Method to your Account with the option to set it as the default method. - API Documentation: https://www.linode.com/docs/api/account/#payment-method-add + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-payment-method :param data: An object representing the credit card information you have on file with Linode to make Payments against your Account. @@ -281,7 +281,7 @@ def notifications(self): """ Returns a collection of Notification objects representing important, often time-sensitive items related to your Account. - API Documentation: https://www.linode.com/docs/api/account/#notifications-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notifications :returns: A list of Notifications on this account. :rtype: List of Notification objects as MappedObjects @@ -297,7 +297,7 @@ def linode_managed_enable(self): """ Enables Linode Managed for the entire account and sends a welcome email to the account’s associated email address. - API Documentation: https://www.linode.com/docs/api/account/#linode-managed-enable + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-enable-account-managed """ resp = self.client.post( @@ -315,7 +315,7 @@ def add_promo_code(self, promo_code): """ Adds an expiring Promo Credit to your account. - API Documentation: https://www.linode.com/docs/api/account/#promo-credit-add + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-promo-credit :param promo_code: The Promo Code. :type promo_code: str @@ -341,7 +341,7 @@ def service_transfers(self): """ Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer. - API Documentation: https://www.linode.com/docs/api/account/#service-transfers-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-service-transfers :returns: A list of Service Transfers on this account. :rtype: PaginatedList of ServiceTransfer @@ -353,7 +353,7 @@ def service_transfer_create(self, entities): """ Creates a transfer request for the specified services. - API Documentation: https://www.linode.com/docs/api/account/#service-transfer-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-service-transfer :param entities: A collection of the services to include in this transfer request, separated by type. :type entities: dict @@ -396,7 +396,7 @@ def transfer(self): """ Returns a MappedObject containing the account's transfer pool data. - API Documentation: https://www.linode.com/docs/api/account/#network-utilization-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-transfer :returns: Information about this account's transfer pool data. :rtype: MappedObject @@ -421,7 +421,7 @@ def user_create(self, email, username, restricted=True): The new user will receive an email inviting them to set up their password. This must be completed before they can log in. - API Documentation: https://www.linode.com/docs/api/account/#user-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-user :param email: The new user's email address. This is used to finish setting up their user account. @@ -459,7 +459,7 @@ def enrolled_betas(self, *filters): """ Returns a list of all Beta Programs an account is enrolled in. - API doc: https://www.linode.com/docs/api/beta-programs/#enrolled-beta-programs-list + API doc: https://techdocs.akamai.com/linode-api/reference/get-enrolled-beta-programs :returns: a list of Beta Programs. :rtype: PaginatedList of AccountBetaProgram @@ -470,7 +470,7 @@ def join_beta_program(self, beta: Union[str, BetaProgram]): """ Enrolls an account into a beta program. - API doc: https://www.linode.com/docs/api/beta-programs/#beta-program-enroll + API doc: https://techdocs.akamai.com/linode-api/reference/post-beta-program :param beta: The object or id of a beta program to join. :type beta: BetaProgram or str @@ -491,7 +491,7 @@ def availabilities(self, *filters): Returns a list of all available regions and the resource types which are available to the account. - API doc: https://www.linode.com/docs/api/account/#region-service-availability + API doc: https://techdocs.akamai.com/linode-api/reference/get-account-availability :returns: a list of region availability information. :rtype: PaginatedList of AccountAvailability diff --git a/linode_api4/groups/beta.py b/linode_api4/groups/beta.py index 1da34ee25..a44fd492d 100644 --- a/linode_api4/groups/beta.py +++ b/linode_api4/groups/beta.py @@ -12,7 +12,7 @@ def betas(self, *filters): """ Returns a list of available active Beta Programs. - API Documentation: https://www.linode.com/docs/api/beta-programs/#beta-programs-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-beta-programs :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 8bddd47d0..957c136cf 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -31,7 +31,7 @@ def types(self, *filters): database_types = client.database.types(DatabaseType.deprecated == False) - API Documentation: https://www.linode.com/docs/api/databases/#managed-database-types-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -51,7 +51,7 @@ def engines(self, *filters): mysql_engines = client.database.engines(DatabaseEngine.engine == 'mysql') - API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engines-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-engines :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -66,7 +66,7 @@ def instances(self, *filters): """ Returns a list of Managed Databases active on this account. - API Documentation: https://www.linode.com/docs/api/databases/#managed-databases-list-all + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-instances :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -81,7 +81,7 @@ def mysql_instances(self, *filters): """ Returns a list of Managed MySQL Databases active on this account. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-databases-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instances :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -112,7 +112,7 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): type.id ) - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instances :param label: The name for this cluster :type label: str @@ -146,7 +146,7 @@ def postgresql_instances(self, *filters): """ Returns a list of Managed PostgreSQL Databases active on this account. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-databases-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgre-sql-instances :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -177,7 +177,7 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): type.id ) - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instances :param label: The name for this cluster :type label: str diff --git a/linode_api4/groups/domain.py b/linode_api4/groups/domain.py index c3b11146d..95bd3c838 100644 --- a/linode_api4/groups/domain.py +++ b/linode_api4/groups/domain.py @@ -13,7 +13,7 @@ def __call__(self, *filters): domains = client.domains() - API Documentation: https://www.linode.com/docs/api/domains/#domains-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-domains :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -30,7 +30,7 @@ def create(self, domain, master=True, **kwargs): your registrar to Linode's nameservers so that Linode's DNS manager will correctly serve your domain. - API Documentation: https://www.linode.com/docs/api/domains/#domain-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-domain :param domain: The domain to register to Linode's DNS manager. :type domain: str diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index e19928d7a..d22363af3 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -18,7 +18,7 @@ def __call__(self, *filters): debian_images = client.images( Image.vendor == "debain") - API Documentation: https://www.linode.com/docs/api/images/#images-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-images :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -33,7 +33,7 @@ def create(self, disk, label=None, description=None, cloud_init=False): """ Creates a new Image from a disk you own. - API Documentation: https://www.linode.com/docs/api/images/#image-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-image :param disk: The Disk to imagize. :type disk: Disk or int @@ -82,7 +82,7 @@ def create_upload( """ Creates a new Image and returns the corresponding upload URL. - API Documentation: https://www.linode.com/docs/api/images/#image-upload + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upload-image :param label: The label of the Image to create. :type label: str @@ -119,7 +119,7 @@ def upload( """ Creates and uploads a new image. - API Documentation: https://www.linode.com/docs/api/images/#image-upload + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upload-image :param label: The label of the Image to create. :type label: str diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 5f69d2b94..5601c855c 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -40,7 +40,7 @@ def types(self, *filters): standard_types = client.linode.types(Type.class == "standard") - API documentation: https://www.linode.com/docs/api/linode-types/#types-list + API documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -58,7 +58,7 @@ def instances(self, *filters): prod_linodes = client.linode.instances(Instance.group == "prod") - API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instances :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -78,7 +78,7 @@ def stackscripts(self, *filters, **kwargs): my_stackscripts = client.linode.stackscripts(mine_only=True) - API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscripts-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-stack-scripts :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -115,7 +115,7 @@ def kernels(self, *filters): Returns a list of available :any:`Kernels`. Kernels are used when creating or updating :any:`LinodeConfigs,LinodeConfig>`. - API Documentation: https://www.linode.com/docs/api/linode-instances/#kernels-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-kernels :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -216,7 +216,7 @@ def instance_create( successfully until disks and configs are created, or it is otherwise configured. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-instance :param ltype: The Instance Type we are creating :type ltype: str or Type @@ -384,7 +384,7 @@ def stackscript_create( """ Creates a new :any:`StackScript` on your account. - API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-add-stack-script :param label: The label for this StackScript. :type label: str diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index 0e2785939..175a730ca 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -29,7 +29,7 @@ def versions(self, *filters): Returns a :any:`PaginatedList` of :any:`KubeVersion` objects that can be used when creating an LKE Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-versions-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-versions :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -45,7 +45,7 @@ def clusters(self, *filters): Returns a :any:`PaginagtedList` of :any:`LKECluster` objects that belong to this account. - https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-clusters-list + https://techdocs.akamai.com/linode-api/reference/get-lke-clusters :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -87,7 +87,7 @@ def cluster_create( kube_version ) - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lke-cluster :param region: The Region to create this LKE Cluster in. :type region: Region or str diff --git a/linode_api4/groups/longview.py b/linode_api4/groups/longview.py index 8caf39962..3f2b292e3 100644 --- a/linode_api4/groups/longview.py +++ b/linode_api4/groups/longview.py @@ -17,7 +17,7 @@ def clients(self, *filters): Requests and returns a paginated list of LongviewClients on your account. - API Documentation: https://www.linode.com/docs/api/longview/#longview-clients-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-longview-clients :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -32,7 +32,7 @@ def client_create(self, label=None): """ Creates a new LongviewClient, optionally with a given label. - API Documentation: https://www.linode.com/docs/api/longview/#longview-client-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-longview-client :param label: The label for the new client. If None, a default label based on the new client's ID will be used. @@ -58,7 +58,7 @@ def subscriptions(self, *filters): """ Requests and returns a paginated list of LongviewSubscriptions available - API Documentation: https://www.linode.com/docs/api/longview/#longview-subscriptions-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-longview-subscriptions :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 606435422..7ba6919e4 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -21,7 +21,7 @@ def firewalls(self, *filters): """ Retrieves the Firewalls your user has access to. - API Documentation: https://www.linode.com/docs/api/networking/#firewalls-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewalls :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -37,7 +37,7 @@ def firewall_create(self, label, rules, **kwargs): Creates a new Firewall, either in the given Region or attached to the given Instance. - API Documentation: https://www.linode.com/docs/api/networking/#firewall-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-firewalls :param label: The label for the new Firewall. :type label: str @@ -74,7 +74,7 @@ def firewall_create(self, label, rules, **kwargs): firewall = client.networking.firewall_create('my-firewall', rules) - .. _Firewalls Documentation: https://www.linode.com/docs/api/networking/#firewall-create__request-body-schema + .. _Firewalls Documentation: https://techdocs.akamai.com/linode-api/reference/post-firewalls """ params = { @@ -97,7 +97,7 @@ def ips(self, *filters): """ Returns a list of IP addresses on this account, excluding private addresses. - API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ips :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -112,7 +112,7 @@ def ipv6_ranges(self, *filters): """ Returns a list of IPv6 ranges on this account. - API Documentation: https://www.linode.com/docs/api/networking/#ipv6-ranges-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ipv6-ranges :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -127,7 +127,7 @@ def ipv6_pools(self, *filters): """ Returns a list of IPv6 pools on this account. - API Documentation: https://www.linode.com/docs/api/networking/#ipv6-pools-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ipv6-pools :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -145,7 +145,7 @@ def vlans(self, *filters): Returns a list of VLANs on your account. - API Documentation: https://www.linode.com/docs/api/networking/#vlans-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vlans :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -184,7 +184,7 @@ def ips_assign(self, region, *assignments): linode1.invalidate() linode2.invalidate() - API Documentation: https://www.linode.com/docs/api/networking/#linodes-assign-ipv4s + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-assign-ipv4s :param region: The Region in which the assignments should take place. All Instances and IPAddresses involved in the assignment @@ -216,7 +216,7 @@ def ip_allocate(self, linode, public=True): Allocates an IP to a Instance you own. Additional IPs must be requested by opening a support ticket first. - API Documentation: https://www.linode.com/docs/api/networking/#ip-address-allocate + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-allocate-ip :param linode: The Instance to allocate the new IP for. :type linode: Instance or int @@ -249,7 +249,7 @@ def ips_share(self, linode, *ips): :any:`Instance`. This will enable the provided Instance to bring up the shared IP Addresses even though it does not own them. - API Documentation: https://www.linode.com/docs/api/networking/#ipv4-sharing-configure + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-share-ipv4s :param linode: The Instance to share the IPAddresses with. This Instance will be able to bring up the given addresses. @@ -289,7 +289,7 @@ def ip_addresses_share(self, ips, linode): primary Linode becomes unresponsive. This means that requests to the primary Linode’s IP address can be automatically rerouted to secondary Linodes at the configured shared IP addresses. - API Documentation: https://www.linode.com/docs/api/networking/#ip-addresses-share + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-share-ips :param linode: The id of the Instance or the Instance to share the IPAddresses with. This Instance will be able to bring up the given addresses. diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 1430ad6a6..50068f8eb 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -13,7 +13,7 @@ def __call__(self, *filters): nodebalancers = client.nodebalancers() - API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancers-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancers :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -28,7 +28,7 @@ def create(self, region, **kwargs): """ Creates a new NodeBalancer in the given Region. - API Documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-node-balancer :param region: The Region in which to create the NodeBalancer. :type region: Region or str diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index c42805ec1..f531932e0 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -38,7 +38,7 @@ def clusters(self, *filters): us_east_clusters = client.object_storage.clusters(ObjectStorageCluster.region == "us-east") - API Documentation: https://www.linode.com/docs/api/object-storage/#clusters-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-clusters :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -54,7 +54,7 @@ def keys(self, *filters): Returns a list of Object Storage Keys active on this account. These keys allow third-party applications to interact directly with Linode Object Storage. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-keys-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-keys :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -105,7 +105,7 @@ def keys_create( bucket_access=client.object_storage.bucket_access("us-east-1", "example2", "read_only"), ) - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-object-storage-keys :param label: The label for this keypair, for identification only. :type label: str @@ -220,7 +220,7 @@ def buckets_in_region(self, region: str, *filters): This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-in-cluster-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-bucketin-cluster :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -245,7 +245,7 @@ def cancel(self): cancelled, you will no longer receive the transfer for or be billed for Object Storage, and all keys will be invalidated. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-cancel + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-cancel-object-storage """ self.client.post("/object-storage/cancel", data={}) return True @@ -256,7 +256,7 @@ def transfer(self): in bytes, for the current month’s billing cycle. Object Storage adds 1 terabyte of outbound data transfer to your data transfer pool. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-transfer-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-transfer :returns: The amount of outbound data transfer used by your account’s Object Storage buckets, in bytes, for the current month’s billing cycle. @@ -278,7 +278,7 @@ def buckets(self, *filters): This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-buckets :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -309,7 +309,7 @@ def bucket_create( This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-object-storage-bucket :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. @@ -401,7 +401,7 @@ def object_url_create( This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-url-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-object-storage-object-url :param cluster_or_region_id: The ID of the cluster or region this bucket exists in. :type cluster_or_region_id: str diff --git a/linode_api4/groups/polling.py b/linode_api4/groups/polling.py index 4141b78b9..7dff2d3d5 100644 --- a/linode_api4/groups/polling.py +++ b/linode_api4/groups/polling.py @@ -19,10 +19,10 @@ def event_poller_create( Creates a new instance of the EventPoller class. :param entity_type: The type of the entity to poll for events on. - Valid values for this field can be found here: https://www.linode.com/docs/api/account/#events-list__responses + Valid values for this field can be found here: https://techdocs.akamai.com/linode-api/reference/get-events :type entity_type: str :param action: The action that caused the Event to poll for. - Valid values for this field can be found here: https://www.linode.com/docs/api/account/#events-list__responses + Valid values for this field can be found here: https://techdocs.akamai.com/linode-api/reference/get-events :type action: str :param entity_id: The ID of the entity to poll for. :type entity_id: int @@ -51,7 +51,7 @@ def wait_for_entity_free( Waits for all events relevant events to not be scheduled or in-progress. :param entity_type: The type of the entity to poll for events on. - Valid values for this field can be found here: https://www.linode.com/docs/api/account/#events-list__responses + Valid values for this field can be found here: https://techdocs.akamai.com/linode-api/reference/get-events :type entity_type: str :param entity_id: The ID of the entity to poll for. :type entity_id: int diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index 8618ed3fd..4c49a2b5a 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -28,7 +28,7 @@ def __call__(self): profile = client.profile() - API Documentation: https://www.linode.com/docs/api/profile/#profile-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile :returns: The acting user's profile. :rtype: Profile @@ -47,7 +47,7 @@ def trusted_devices(self): """ Returns the Trusted Devices on your profile. - API Documentation: https://www.linode.com/docs/api/profile/#trusted-devices-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-devices :returns: A list of Trusted Devices for this profile. :rtype: PaginatedList of TrustedDevice @@ -69,7 +69,7 @@ def security_questions(self): """ Returns a collection of security questions and their responses, if any, for your User Profile. - API Documentation: https://www.linode.com/docs/api/profile/#security-questions-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-security-questions """ result = self.client.get( @@ -120,7 +120,7 @@ def phone_number_delete(self): """ Delete the verified phone number for the User making this request. - API Documentation: https://www.linode.com/docs/api/profile/#phone-number-delete + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-profile-phone-number :returns: Returns True if the operation was successful. :rtype: bool @@ -143,7 +143,7 @@ def phone_number_verify(self, otp_code): Verify a phone number by confirming the one-time code received via SMS message after accessing the Phone Verification Code Send (POST /profile/phone-number) command. - API Documentation: https://www.linode.com/docs/api/profile/#phone-number-verify + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-profile-phone-number-verify :param otp_code: The one-time code received via SMS message after accessing the Phone Verification Code Send :type otp_code: str @@ -175,7 +175,7 @@ def phone_number_verification_code_send(self, iso_code, phone_number): """ Send a one-time verification code via SMS message to the submitted phone number. - API Documentation: https://www.linode.com/docs/api/profile/#phone-number-verification-code-send + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-profile-phone-number :param iso_code: The two-letter ISO 3166 country code associated with the phone number. :type iso_code: str @@ -213,7 +213,7 @@ def logins(self): """ Returns the logins on your profile. - API Documentation: https://www.linode.com/docs/api/profile/#logins-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile-logins :returns: A list of logins for this profile. :rtype: PaginatedList of ProfileLogin @@ -224,7 +224,7 @@ def tokens(self, *filters): """ Returns the Person Access Tokens active for this user. - API Documentation: https://www.linode.com/docs/api/profile/#personal-access-tokens-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-personal-access-tokens :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -239,7 +239,7 @@ def token_create(self, label=None, expiry=None, scopes=None, **kwargs): """ Creates and returns a new Personal Access Token. - API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-personal-access-token :param label: The label of the new Personal Access Token. :type label: str @@ -275,7 +275,7 @@ def apps(self, *filters): """ Returns the Authorized Applications for this user - API Documentation: https://www.linode.com/docs/api/profile/#authorized-apps-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile-apps :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -290,7 +290,7 @@ def ssh_keys(self, *filters): """ Returns the SSH Public Keys uploaded to your profile. - API Documentation: https://www.linode.com/docs/api/profile/#ssh-keys-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ssh-keys :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -306,7 +306,7 @@ def ssh_key_upload(self, key, label): Uploads a new SSH Public Key to your profile This key can be used in later Linode deployments. - API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-add + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-add-ssh-key :param key: The ssh key, or a path to the ssh key. If a path is provided, the file at the path must exist and be readable or an exception diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index 9ddc8fb63..baf8697e4 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -13,7 +13,7 @@ def __call__(self, *filters): region = client.regions() - API Documentation: https://www.linode.com/docs/api/regions/#regions-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -30,7 +30,7 @@ def availability(self, *filters): Returns the availability of Linode plans within a Region. - API Documentation: https://www.linode.com/docs/api/regions/#regions-availability-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-availability :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/support.py b/linode_api4/groups/support.py index 565128b2f..ccc0b154d 100644 --- a/linode_api4/groups/support.py +++ b/linode_api4/groups/support.py @@ -23,7 +23,7 @@ def tickets(self, *filters): """ Returns a list of support tickets on this account. - API Documentation: https://www.linode.com/docs/api/support/#support-tickets-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-tickets :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -46,7 +46,7 @@ def ticket_open( """ Opens a support ticket on this account. - API Documentation: https://www.linode.com/docs/api/support/#support-ticket-open + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ticket :param summary: The summary or title for this support ticket. :type summary: str diff --git a/linode_api4/groups/tag.py b/linode_api4/groups/tag.py index ebf733159..5948b513b 100644 --- a/linode_api4/groups/tag.py +++ b/linode_api4/groups/tag.py @@ -14,7 +14,7 @@ def __call__(self, *filters): tags = client.tags() - API Documentation: https://www.linode.com/docs/api/domains/#domain-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-domain :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -37,7 +37,7 @@ def create( """ Creates a new Tag and optionally applies it to the given entities. - API Documentation: https://www.linode.com/docs/api/tags/#tags-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-tags :param label: The label for the new Tag :type label: str diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index b27ebf8ba..edbfdfbf8 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -13,7 +13,7 @@ def __call__(self, *filters): volumes = client.volumes() - API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-volumes :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -29,7 +29,7 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): Creates a new Block Storage Volume, either in the given Region or attached to the given Instance. - API Documentation: https://www.linode.com/docs/api/volumes/#volumes-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-volumes :param label: The label for the new Volume. :type label: str diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 8c5ad098f..31e3cf33d 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -26,7 +26,7 @@ class Account(Base): """ The contact and billing information related to your Account. - API Documentation: https://www.linode.com/docs/api/account/#account-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account """ api_endpoint = "/account" @@ -93,7 +93,7 @@ class ServiceTransfer(Base): """ A transfer request for transferring a service between Linode accounts. - API Documentation: https://www.linode.com/docs/api/account/#service-transfer-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-service-transfer """ api_endpoint = "/account/service-transfers/{token}" @@ -112,7 +112,7 @@ def service_transfer_accept(self): """ Accept a Service Transfer for the provided token to receive the services included in the transfer to your account. - API Documentation: https://www.linode.com/docs/api/account/#service-transfer-accept + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-accept-service-transfer """ resp = self._client.post( @@ -131,7 +131,7 @@ class PaymentMethod(Base): """ A payment method to be used on this Linode account. - API Documentation: https://www.linode.com/docs/api/account/#payment-method-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-payment-method """ api_endpoint = "/account/payment-methods/{id}" @@ -147,7 +147,7 @@ def payment_method_make_default(self): """ Make this Payment Method the default method for automatically processing payments. - API Documentation: https://www.linode.com/docs/api/account/#payment-method-make-default + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-make-payment-method-default """ resp = self._client.post( @@ -166,7 +166,7 @@ class Login(Base): """ A login entry for this account. - API Documentation: https://www.linode.com/docs/api/account/#login-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-login """ api_endpoint = "/account/logins/{id}" @@ -184,7 +184,7 @@ class AccountSettings(Base): """ Information related to your Account settings. - API Documentation: https://www.linode.com/docs/api/account/#account-settings-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-account-settings """ api_endpoint = "/account/settings" @@ -205,7 +205,7 @@ class Event(Base): """ An event object representing an event that took place on this account. - API Documentation: https://www.linode.com/docs/api/account/#event-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-event """ api_endpoint = "/account/events/{id}" @@ -310,7 +310,7 @@ def mark_read(self): """ Marks a single Event as read. - API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-read + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-event-read """ self._client.post("{}/read".format(Event.api_endpoint), model=self) @@ -319,7 +319,7 @@ def mark_seen(self): """ Marks a single Event as seen. - API Documentation: https://www.linode.com/docs/api/account/#event-mark-as-seen + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-event-seen """ self._client.post("{}/seen".format(Event.api_endpoint), model=self) @@ -329,7 +329,7 @@ class InvoiceItem(DerivedBase): """ An individual invoice item under an :any:`Invoice` object. - API Documentation: https://www.linode.com/docs/api/account/#invoice-items-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-invoice-items """ api_endpoint = "/account/invoices/{invoice_id}/items" @@ -364,7 +364,7 @@ class Invoice(Base): """ A single invoice on this Linode account. - API Documentation: https://www.linode.com/docs/api/account/#invoice-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-invoice """ api_endpoint = "/account/invoices/{id}" @@ -385,7 +385,7 @@ class OAuthClient(Base): """ An OAuthClient object that can be used to authenticate apps with this account. - API Documentation: https://www.linode.com/docs/api/account/#oauth-client-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-client """ api_endpoint = "/account/oauth-clients/{id}" @@ -404,7 +404,7 @@ def reset_secret(self): """ Resets the client secret for this client. - API Documentation: https://www.linode.com/docs/api/account/#oauth-client-secret-reset + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reset-client-secret """ result = self._client.post( "{}/reset_secret".format(OAuthClient.api_endpoint), model=self @@ -424,7 +424,7 @@ def thumbnail(self, dump_to=None): If dump_to is given, attempts to write the image to a file at the given location. - API Documentation: https://www.linode.com/docs/api/account/#oauth-client-thumbnail-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-client-thumbnail """ headers = {"Authorization": "token {}".format(self._client.token)} @@ -452,7 +452,7 @@ def set_thumbnail(self, thumbnail): uploads it as a png. Otherwise, assumes thumbnail is a path to the thumbnail and reads it in as bytes before uploading. - API Documentation: https://www.linode.com/docs/api/account/#oauth-client-thumbnail-update + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-client-thumbnail """ headers = { "Authorization": "token {}".format(self._client.token), @@ -487,7 +487,7 @@ class Payment(Base): """ An object representing a single payment on the current Linode Account. - API Documentation: https://www.linode.com/docs/api/account/#payment-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-payment """ api_endpoint = "/account/payments/{id}" @@ -503,7 +503,7 @@ class User(Base): """ An object representing a single user on this account. - API Documentation: https://www.linode.com/docs/api/account/#user-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user """ api_endpoint = "/account/users/{id}" @@ -525,7 +525,7 @@ def grants(self): will result in an ApiError. This is smart, and will only fetch from the api once unless the object is invalidated. - API Documentation: https://www.linode.com/docs/api/account/#users-grants-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user-grants :returns: The grants for this user. :rtype: linode.objects.account.UserGrants @@ -624,7 +624,7 @@ class UserGrants: a Base-like model (such as a unique, ID-based endpoint at which to access it), however it has some similarities so that its usage is familiar. - API Documentation: https://www.linode.com/docs/api/account/#users-grants-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user-grants """ api_endpoint = "/account/users/{username}/grants" @@ -650,7 +650,7 @@ def save(self): """ Applies the grants to the parent user. - API Documentation: https://www.linode.com/docs/api/account/#users-grants-update + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-user-grants """ req = { @@ -680,7 +680,7 @@ class AccountBetaProgram(Base): """ The details and enrollment information of a Beta program that an account is enrolled in. - API Documentation: https://www.linode.com/docs/api/beta-programs/#enrolled-beta-program-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-enrolled-beta-program """ api_endpoint = "/account/betas/{id}" @@ -700,7 +700,7 @@ class AccountAvailability(Base): Contains information about the resources available for a region under the current account. - API doc: https://www.linode.com/docs/api/account/#region-service-availability + API doc: https://techdocs.akamai.com/linode-api/reference/get-account-availability """ api_endpoint = "/account/availability/{region}" diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index 42a3eef85..c957aa584 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -6,7 +6,7 @@ class BetaProgram(Base): Beta program is a new product or service that's not generally available to all customers. User with permissions can enroll into a beta program and access the functionalities. - API Documentation: https://www.linode.com/docs/api/beta-programs/#beta-program-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-beta-program """ api_endpoint = "/betas/{id}" diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index f71115758..6a028722c 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -5,7 +5,7 @@ class DatabaseType(Base): """ The type of a managed database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-database-type-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-type """ api_endpoint = "/databases/types/{id}" @@ -40,7 +40,7 @@ class DatabaseEngine(Base): - MySQL - PostgreSQL - API Documentation: https://www.linode.com/docs/api/databases/#managed-database-engine-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-engine """ api_endpoint = "/databases/engines/{id}" @@ -88,8 +88,8 @@ def restore(self): API Documentation: - - MySQL: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-restore - - PostgreSQL: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-restore + - MySQL: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup-restore + - PostgreSQL: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup-restore """ return self._client.post( @@ -101,7 +101,7 @@ class MySQLDatabaseBackup(DatabaseBackup): """ A backup for an accessible Managed MySQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance-backup """ api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" @@ -111,7 +111,7 @@ class PostgreSQLDatabaseBackup(DatabaseBackup): """ A backup for an accessible Managed PostgreSQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-instance-backup """ api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" @@ -121,7 +121,7 @@ class MySQLDatabase(Base): """ An accessible Managed MySQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance """ api_endpoint = "/databases/mysql/instances/{id}" @@ -153,7 +153,7 @@ def credentials(self): Display the root username and password for an accessible Managed MySQL Database. The Database must have an active status to perform this command. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-credentials-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance-credentials :returns: MappedObject containing credntials for this DB :rtype: MappedObject @@ -172,7 +172,7 @@ def ssl(self): """ Display the SSL CA certificate for an accessible Managed MySQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-ssl-certificate-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance-ssl :returns: MappedObject containing SSL CA certificate for this DB :rtype: MappedObject @@ -190,7 +190,7 @@ def credentials_reset(self): """ Reset the root password for a Managed MySQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-credentials-reset + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-credentials-reset :returns: Response from the API call to reset credentials :rtype: dict @@ -207,7 +207,7 @@ def patch(self): """ Apply security patches and updates to the underlying operating system of the Managed MySQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-patch + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-patch :returns: Response from the API call to apply security patches :rtype: dict @@ -223,7 +223,7 @@ def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed MySQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-mysql-database-backup-snapshot-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup """ params = { @@ -254,7 +254,7 @@ class PostgreSQLDatabase(Base): """ An accessible Managed PostgreSQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgre-sql-instance """ api_endpoint = "/databases/postgresql/instances/{id}" @@ -287,7 +287,7 @@ def credentials(self): Display the root username and password for an accessible Managed PostgreSQL Database. The Database must have an active status to perform this command. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-credentials-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgre-sql-instance-credentials :returns: MappedObject containing credntials for this DB :rtype: MappedObject @@ -307,7 +307,7 @@ def ssl(self): """ Display the SSL CA certificate for an accessible Managed PostgreSQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-ssl-certificate-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-instance-ssl :returns: MappedObject containing SSL CA certificate for this DB :rtype: MappedObject @@ -325,7 +325,7 @@ def credentials_reset(self): """ Reset the root password for a Managed PostgreSQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-credentials-reset + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-credentials-reset :returns: Response from the API call to reset credentials :rtype: dict @@ -342,7 +342,7 @@ def patch(self): """ Apply security patches and updates to the underlying operating system of the Managed PostgreSQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-patch + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-patch :returns: Response from the API call to apply security patches :rtype: dict @@ -358,7 +358,7 @@ def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed PostgreSQL Database. - API Documentation: https://www.linode.com/docs/api/databases/#managed-postgresql-database-backup-snapshot-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup """ params = { diff --git a/linode_api4/objects/domain.py b/linode_api4/objects/domain.py index aeca7d837..8ce7a5ee4 100644 --- a/linode_api4/objects/domain.py +++ b/linode_api4/objects/domain.py @@ -6,7 +6,7 @@ class DomainRecord(DerivedBase): """ A single record on a Domain. - API Documentation: https://www.linode.com/docs/api/domains/#domain-record-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-domain-record """ api_endpoint = "/domains/{domain_id}/records/{id}" @@ -37,7 +37,7 @@ class Domain(Base): Linode is not a registrar, and in order for this Domain record to work you must own the domain and point your registrar at Linode’s nameservers. - API Documentation: https://www.linode.com/docs/api/domains/#domain-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-domain """ api_endpoint = "/domains/{id}" @@ -64,7 +64,7 @@ def record_create(self, record_type, **kwargs): Adds a new Domain Record to the zonefile this Domain represents. Each domain can have up to 12,000 active records. - API Documentation: https://www.linode.com/docs/api/domains/#domain-record-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-domain-record :param record_type: The type of Record this is in the DNS system. Can be one of: A, AAAA, NS, MX, CNAME, TXT, SRV, PTR, CAA. @@ -101,7 +101,7 @@ def zone_file_view(self): """ Returns the zone file for the last rendered zone for the specified domain. - API Documentation: https://www.linode.com/docs/api/domains/#domain-zone-file-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-domain-zone :returns: The zone file for the last rendered zone for the specified domain in the form of a list of the lines of the zone file. @@ -118,7 +118,7 @@ def clone(self, domain: str): """ Clones a Domain and all associated DNS records from a Domain that is registered in Linode’s DNS manager. - API Documentation: https://www.linode.com/docs/api/domains/#domain-clone + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-clone-domain :param domain: The new domain for the clone. Domain labels cannot be longer than 63 characters and must conform to RFC1035. Domains must be @@ -143,7 +143,7 @@ def domain_import(self, domain, remote_nameserver): - 2600:3c00::5e = 2600:3c00::5f - API Documentation: https://www.linode.com/docs/api/domains/#domain-import + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-import-domain :param domain: The domain to import. :type: domain: str diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index a919d25e0..2317dd20d 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -5,7 +5,7 @@ class Image(Base): """ An Image is something a Linode Instance or Disk can be deployed from. - API Documentation: https://www.linode.com/docs/api/images/#image-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-image """ api_endpoint = "/images/{id}" diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index d86ec1746..1b102da37 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -32,7 +32,7 @@ class Backup(DerivedBase): """ A Backup of a Linode Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#backup-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-backup """ api_endpoint = "/linode/instances/{linode_id}/backups/{id}" @@ -60,7 +60,7 @@ def restore_to(self, linode, **kwargs): """ Restores a Linode’s Backup to the specified Linode. - API Documentation: https://www.linode.com/docs/api/linode-instances/#backup-restore + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-restore-backup :param linode: The id of the Instance or the Instance to share the IPAddresses with. This Instance will be able to bring up the given addresses. @@ -98,7 +98,7 @@ class Disk(DerivedBase): """ A Disk for the storage space on a Compute Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-disk """ api_endpoint = "/linode/instances/{linode_id}/disks/{id}" @@ -121,7 +121,7 @@ def duplicate(self): Copies a disk, byte-for-byte, into a new Disk belonging to the same Linode. The Linode must have enough storage space available to accept a new Disk of the same size as this one or this operation will fail. - API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-clone + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-clone-linode-disk :returns: A Disk object representing the cloned Disk :rtype: Disk @@ -140,7 +140,7 @@ def reset_root_password(self, root_password=None): """ Resets the password of a Disk you have permission to read_write. - API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-root-password-reset + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reset-disk-password :param root_password: The new root password for the OS installed on this Disk. The password must meet the complexity strength validation requirements for a strong password. @@ -168,7 +168,7 @@ def resize(self, new_size): fit on the new disk size. You may need to resize the filesystem on the disk first before performing this action. - API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-resize + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-resize-disk :param new_size: The intended new size of the disk, in MB :type new_size: int @@ -208,7 +208,7 @@ class Kernel(Base): to compile the kernel from source than to download it from your package manager. For more information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS. - API Documentation: https://www.linode.com/docs/api/linode-instances/#kernel-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-kernel """ api_endpoint = "/linode/kernels/{id}" @@ -232,7 +232,7 @@ class Type(Base): """ Linode Plan type to specify the resources available to a Linode Instance. - API Documentation: https://www.linode.com/docs/api/linode-types/#type-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-type """ api_endpoint = "/linode/types/{id}" @@ -416,7 +416,7 @@ class Config(DerivedBase): """ A Configuration Profile for a Linode Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#configuration-profile-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config """ api_endpoint = "/linode/instances/{linode_id}/configs/{id}" @@ -636,7 +636,7 @@ class Instance(Base): """ A Linode Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-instance """ api_endpoint = "/linode/instances/{id}" @@ -670,7 +670,7 @@ def ips(self): The ips related collection is not normalized like the others, so we have to make an ad-hoc object to return for its response - API Documentation: https://www.linode.com/docs/api/linode-instances/#networking-information-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-ips :returns: A List of the ips of the Linode Instance. :rtype: List[IPAddress] @@ -751,7 +751,7 @@ def available_backups(self): """ The backups response contains what backups are available to be restored. - API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-backups :returns: A List of the available backups for the Linode Instance. :rtype: List[Backup] @@ -809,7 +809,7 @@ def reset_instance_root_password(self, root_password=None): """ Resets the root password for this Linode. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-root-password-reset + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reset-linode-password :param root_password: The root user’s password on this Linode. Linode passwords must meet a password strength score requirement that is calculated internally @@ -833,7 +833,7 @@ def transfer_year_month(self, year, month): """ Get per-linode transfer for specified month - API Documentation: https://www.linode.com/docs/api/linode-instances/#network-transfer-view-yearmonth + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-transfer-by-year-month :param year: Numeric value representing the year to look up. :type: year: int @@ -861,7 +861,7 @@ def transfer(self): """ Get per-linode transfer - API Documentation: https://www.linode.com/docs/api/linode-instances/#network-transfer-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-transfer :returns: The network transfer statistics for the current month. :rtype: MappedObject @@ -951,7 +951,7 @@ def boot(self, config=None): (because the Linode was never booted or the last booted config was deleted) an error will be returned. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-boot + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-boot-linode-instance :param config: The Linode Config ID to boot into. :type: config: int @@ -976,7 +976,7 @@ def shutdown(self): are currently running or queued, those actions must be completed first before you can initiate a shutdown. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-shut-down + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-shutdown-linode-instance :returns: True if the operation was successful. :rtype: bool @@ -995,7 +995,7 @@ def reboot(self): Reboots a Linode you have permission to modify. If any actions are currently running or queued, those actions must be completed first before you can initiate a reboot. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-reboot + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-reboot-linode-instance :returns: True if the operation was successful. :rtype: bool @@ -1027,7 +1027,7 @@ def resize( - The Linode must not have more disk allocation than the new Type allows. - In that situation, you must first delete or resize the disk to be smaller. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-resize + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-resize-linode-instance :param new_type: The Linode Type or the id representing it. :type: new_type: Type or int @@ -1096,7 +1096,7 @@ def config_create( """ Creates a Linode Config with the given attributes. - API Documentation: https://www.linode.com/docs/api/linode-instances/#configuration-profile-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-add-linode-config :param kernel: The kernel to boot with. :param label: The config label @@ -1211,7 +1211,7 @@ def disk_create( """ Creates a new Disk for this Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#disk-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-add-linode-disk :param size: The size of the disk, in MB :param label: The label of the disk. If not given, a default label will be generated. @@ -1296,7 +1296,7 @@ def enable_backups(self): For more information on Instance's Backups service and pricing, see our Backups Page: https://www.linode.com/backups - API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-enable + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-enable-backups :returns: True if the operation was successful. :rtype: bool @@ -1313,7 +1313,7 @@ def cancel_backups(self): including any snapshots that have been taken. This cannot be undone, but Backups can be re-enabled at a later date. - API Documentation: https://www.linode.com/docs/api/linode-instances/#backups-cancel + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-cancel-backups :returns: True if the operation was successful. :rtype: bool @@ -1331,7 +1331,7 @@ def snapshot(self, label=None): Important: If you already have a snapshot of this Linode, this is a destructive action. The previous snapshot will be deleted. - API Documentation: https://www.linode.com/docs/api/linode-instances/#snapshot-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-snapshot :param label: The label for the new snapshot. :type: label: str @@ -1365,7 +1365,7 @@ def ip_allocate(self, public=False): before you can add one. You may only have, at most, one private IP per Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#ipv4-address-allocate + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-add-linode-ip :param public: If the new IP should be public or private. Defaults to private. @@ -1397,7 +1397,7 @@ def rebuild(self, image, root_pass=None, authorized_keys=None, **kwargs): a new :any:`Image` to it. This can be used to reset an existing Instance or to install an Image on an empty Instance. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-rebuild + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-rebuild-linode-instance :param image: The Image to deploy to this Instance :type image: str or Image @@ -1455,7 +1455,7 @@ def rescue(self, *disks): Note that “sdh” is reserved and unavailable during rescue. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-boot-into-rescue-mode + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-rescue-linode-instance :param disks: Devices that are either Disks or Volumes :type: disks: dict @@ -1495,7 +1495,7 @@ def mutate(self, allow_auto_disk_resize=True): """ Upgrades this Instance to the latest generation type - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-upgrade + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-mutate-linode-instance :param allow_auto_disk_resize: Automatically resize disks when resizing a Linode. When resizing down to a smaller plan your Linode’s @@ -1527,7 +1527,7 @@ def initiate_migration( Initiates a pending migration that is already scheduled for this Linode Instance - API Documentation: https://www.linode.com/docs/api/linode-instances/#dc-migrationpending-host-migration-initiate + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-migrate-linode-instance :param region: The region to which the Linode will be migrated. Must be a valid region slug. A list of regions can be viewed by using the GET /regions endpoint. A cross data @@ -1572,7 +1572,7 @@ def firewalls(self): """ View Firewall information for Firewalls associated with this Linode. - API Documentation: https://www.linode.com/docs/api/linode-instances/#firewalls-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-firewalls :returns: A List of Firewalls of the Linode Instance. :rtype: List[Firewall] @@ -1594,7 +1594,7 @@ def nodebalancers(self): """ View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-nodebalancers-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-node-balancers :returns: A List of Nodebalancers of the Linode Instance. :rtype: List[Nodebalancer] @@ -1616,7 +1616,7 @@ def volumes(self): """ View Block Storage Volumes attached to this Linode. - API Documentation: https://www.linode.com/docs/api/linode-instances/#linodes-volumes-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-volumes :returns: A List of Volumes of the Linode Instance. :rtype: List[Volume] @@ -1651,7 +1651,7 @@ def clone( """ Clones this linode into a new linode or into a new linode in the given region - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-clone + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-clone-linode-instance :param to_linode: If an existing Linode is the target for the clone, the ID of that Linode. The existing Linode must have enough resources to accept the clone. @@ -1745,7 +1745,7 @@ def stats(self): """ Returns the JSON stats for this Instance - API Documentation: https://www.linode.com/docs/api/linode-instances/#linode-statistics-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-stats :returns: The JSON stats for this Instance :rtype: dict @@ -1759,7 +1759,7 @@ def stats_for(self, dt): """ Returns stats for the month containing the given datetime - API Documentation: https://www.linode.com/docs/api/linode-instances/#statistics-view-yearmonth + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-stats-by-year-month :param dt: A Datetime for which to return statistics :type: dt: Datetime @@ -1803,7 +1803,7 @@ class StackScript(Base): A script allowing users to reproduce specific software configurations when deploying Compute Instances, with more user control than static system images. - API Documentation: https://www.linode.com/docs/api/stackscripts/#stackscript-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-stack-script """ api_endpoint = "/linode/stackscripts/{id}" diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 4d3ec5a16..09a589355 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -19,7 +19,7 @@ class KubeVersion(Base): """ A KubeVersion is a version of Kubernetes that can be deployed on LKE. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-version-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-version """ api_endpoint = "/lke/versions/{id}" @@ -120,7 +120,7 @@ class LKENodePool(DerivedBase): An LKE Node Pool describes a pool of Linode Instances that exist within an LKE Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-node-pool """ api_endpoint = "/lke/clusters/{cluster_id}/pools/{id}" @@ -163,7 +163,7 @@ def recycle(self): Completing this operation may take several minutes. This operation will cause all local data on Linode Instances in this pool to be lost. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-recycle + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lke-cluster-pool-recycle """ self._client.post( "{}/recycle".format(LKENodePool.api_endpoint), model=self @@ -175,7 +175,7 @@ class LKECluster(Base): """ An LKE Cluster is a single k8s cluster deployed via Linode Kubernetes Engine. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster """ api_endpoint = "/lke/clusters/{id}" @@ -212,7 +212,7 @@ def api_endpoints(self): """ A list of API Endpoints for this Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-api-endpoints-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-api-endpoints :returns: A list of MappedObjects of the API Endpoints :rtype: List[MappedObject] @@ -250,7 +250,7 @@ def kubeconfig(self): It may take a few minutes for a config to be ready when creating a new cluster; during that time this request may fail. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubeconfig-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-kubeconfig :returns: The Kubeconfig file for this Cluster. :rtype: str @@ -290,7 +290,7 @@ def node_pool_create(self, node_type, node_count, **kwargs): """ Creates a new :any:`LKENodePool` for this cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-pool-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lke-cluster-pools :param node_type: The type of nodes to create in this pool. :type node_type: :any:`Type` or str @@ -324,7 +324,7 @@ def cluster_dashboard_url_view(self): """ Get a Kubernetes Dashboard access URL for this Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-dashboard-url-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-dashboard :returns: The Kubernetes Dashboard access URL for this Cluster. :rtype: str @@ -340,7 +340,7 @@ def kubeconfig_delete(self): """ Delete and regenerate the Kubeconfig file for a Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubeconfig-delete + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-cluster-kubeconfig """ self._client.delete( @@ -351,7 +351,7 @@ def node_view(self, nodeId): """ Get a specific Node by ID. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-node :param nodeId: ID of the Node to look up. :type nodeId: str @@ -373,7 +373,7 @@ def node_delete(self, nodeId): """ Delete a specific Node from a Node Pool. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-delete + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-cluster-node :param nodeId: ID of the Node to delete. :type nodeId: str @@ -390,7 +390,7 @@ def node_recycle(self, nodeId): """ Recycle a specific Node from an LKE cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#node-recycle + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lke-cluster-node-recycle :param nodeId: ID of the Node to recycle. :type nodeId: str @@ -407,7 +407,7 @@ def cluster_nodes_recycle(self): """ Recycles all nodes in all pools of a designated Kubernetes Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#cluster-nodes-recycle + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lke-cluster-recycle """ self._client.post( @@ -418,7 +418,7 @@ def cluster_regenerate(self): """ Regenerate the Kubeconfig file and/or the service account token for a Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#kubernetes-cluster-regenerate + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lke-cluster-regenerate """ self._client.post( @@ -429,7 +429,7 @@ def service_token_delete(self): """ Delete and regenerate the service account token for a Cluster. - API Documentation: https://www.linode.com/docs/api/linode-kubernetes-engine-lke/#service-token-delete + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-service-token """ self._client.delete( diff --git a/linode_api4/objects/longview.py b/linode_api4/objects/longview.py index 9d883693a..7a1ed56d5 100644 --- a/linode_api4/objects/longview.py +++ b/linode_api4/objects/longview.py @@ -5,7 +5,7 @@ class LongviewClient(Base): """ A Longview Client that is accessible for use. Longview is Linode’s system data graphing service. - API Documentation: https://www.linode.com/docs/api/longview/#longview-client-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-longview-client """ api_endpoint = "/longview/clients/{id}" @@ -25,7 +25,7 @@ class LongviewSubscription(Base): """ Contains the Longview Plan details for a specific subscription id. - API Documentation: https://www.linode.com/docs/api/longview/#longview-subscription-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-longview-subscription """ api_endpoint = "/longview/subscriptions/{id}" @@ -42,7 +42,7 @@ class LongviewPlan(Base): """ The current Longview Plan an account is using. - API Documentation: https://www.linode.com/docs/api/longview/#longview-plan-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-longview-plan """ api_endpoint = "/longview/plan" diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index dac295360..993961098 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -23,7 +23,7 @@ class IPv6Range(Base): """ An instance of a Linode IPv6 Range. - API Documentation: https://www.linode.com/docs/api/networking/#ipv6-range-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ipv6-range """ api_endpoint = "/networking/ipv6/ranges/{range}" @@ -68,7 +68,7 @@ class IPAddress(Base): # Re-populate all attributes with new information from the API ip.invalidate() - API Documentation: https://www.linode.com/docs/api/networking/#ip-address-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ip """ api_endpoint = "/networking/ips/{address}" @@ -144,7 +144,7 @@ class VLAN(Base): VLANs provide a mechanism for secure communication between two or more Linodes that are assigned to the same VLAN. VLANs are implicitly created during Instance or Instance Config creation. - API Documentation: https://www.linode.com/docs/api/networking/#vlans-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vlans """ api_endpoint = "/networking/vlans/{label}" @@ -162,7 +162,7 @@ class FirewallDevice(DerivedBase): """ An object representing the assignment between a Linode Firewall and another Linode resource. - API Documentation: https://www.linode.com/docs/api/networking/#firewall-device-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-device """ api_endpoint = "/networking/firewalls/{firewall_id}/devices/{id}" @@ -183,7 +183,7 @@ class Firewall(Base): An instance of a Linode Cloud Firewall. - API Documentation: https://www.linode.com/docs/api/networking/#firewall-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall """ api_endpoint = "/networking/firewalls/{id}" @@ -203,7 +203,7 @@ def update_rules(self, rules): """ Sets the JSON rules for this Firewall. - API Documentation: https://www.linode.com/docs/api/networking/#firewall-rules-update__request-samples + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-firewall-rules :param rules: The rules to apply to this Firewall. :type rules: dict @@ -217,7 +217,7 @@ def get_rules(self): """ Gets the JSON rules for this Firewall. - API Documentation: https://www.linode.com/docs/api/networking/#firewall-rules-update__request-samples + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-firewall-rules :returns: The rules that this Firewall is currently configured with. :rtype: dict @@ -230,7 +230,7 @@ def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall - API Documentation: https://www.linode.com/docs/api/networking/#firewall-device-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-firewall-device :param id: The ID of the entity to create a device for. :type id: int diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index c6f161ac8..2aeb6180c 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -16,7 +16,7 @@ class NodeBalancerNode(DerivedBase): """ The information about a single Node, a backend for this NodeBalancer’s configured port. - API documentation: https://www.linode.com/docs/api/nodebalancers/#node-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-node """ api_endpoint = ( @@ -61,7 +61,7 @@ class NodeBalancerConfig(DerivedBase): """ The configuration information for a single port of this NodeBalancer. - API documentation: https://www.linode.com/docs/api/nodebalancers/#config-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-config """ api_endpoint = "/nodebalancers/{nodebalancer_id}/configs/{id}" @@ -100,7 +100,7 @@ def nodes(self): Returns a paginated list of NodeBalancer nodes associated with this Config. These are the backends that will be sent traffic for this port. - API documentation: https://www.linode.com/docs/api/nodebalancers/#nodes-list + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-config-nodes :returns: A paginated list of NodeBalancer nodes. :rtype: PaginatedList of NodeBalancerNode @@ -127,7 +127,7 @@ def node_create(self, label, address, **kwargs): NodeBalancer Config. Nodes are routed requests on the configured port based on their status. - API documentation: https://www.linode.com/docs/api/nodebalancers/#node-create + API documentation: https://techdocs.akamai.com/linode-api/reference/post-node-balancer-node :param address: The private IP Address where this backend can be reached. This must be a private IP address. @@ -200,7 +200,7 @@ class NodeBalancer(Base): """ A single NodeBalancer you can access. - API documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer """ api_endpoint = "/nodebalancers/{id}" @@ -227,7 +227,7 @@ def config_create(self, **kwargs): on a new port. You will need to add NodeBalancer Nodes to the new Config before it can actually serve requests. - API documentation: https://www.linode.com/docs/api/nodebalancers/#config-create + API documentation: https://techdocs.akamai.com/linode-api/reference/post-node-balancer-config :returns: The config that created successfully. :rtype: NodeBalancerConfig @@ -254,7 +254,7 @@ def config_rebuild(self, config_id, nodes, **kwargs): Rebuilds a NodeBalancer Config and its Nodes that you have permission to modify. Use this command to update a NodeBalancer’s Config and Nodes with a single request. - API documentation: https://www.linode.com/docs/api/nodebalancers/#config-rebuild + API documentation: https://techdocs.akamai.com/linode-api/reference/post-rebuild-node-balancer-config :param config_id: The ID of the Config to access. :type config_id: int @@ -289,7 +289,7 @@ def statistics(self): """ Returns detailed statistics about the requested NodeBalancer. - API documentation: https://www.linode.com/docs/api/nodebalancers/#nodebalancer-statistics-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-stats :returns: The requested stats. :rtype: MappedObject diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 2cbcf59bd..f4ddfe9b5 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -32,7 +32,7 @@ class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. - API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-bucket """ api_endpoint = "/object-storage/buckets/{region}/{label}" @@ -89,7 +89,7 @@ def access_modify( and/or setting canned ACLs. For more fine-grained control of both systems, please use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-modify + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-object-storage-bucket-access :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. @@ -130,7 +130,7 @@ def access_update( and/or setting canned ACLs. For more fine-grained control of both systems, please use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-access-update + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-storage-bucket-access :param acl: The Access Control Level of the bucket using a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. @@ -165,7 +165,7 @@ def ssl_cert_delete(self): Deletes this Object Storage bucket’s user uploaded TLS/SSL certificate and private key. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-delete + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-object-storage-ssl :returns: True if the TLS/SSL certificate and private key in the bucket were successfully deleted. :rtype: bool @@ -189,7 +189,7 @@ def ssl_cert(self): if this bucket has a corresponding TLS/SSL certificate that was uploaded by an Account user. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-ssl :returns: A result object which has a bool field indicating if this Bucket has a corresponding TLS/SSL certificate that was uploaded by an Account user. @@ -217,7 +217,7 @@ def ssl_cert_upload(self, certificate, private_key): To replace an expired certificate, delete your current certificate and upload a new one. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-tlsssl-cert-upload + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-object-storage-ssl :param certificate: Your Base64 encoded and PEM formatted SSL certificate. Line breaks must be represented as “\n” in the string @@ -267,7 +267,7 @@ def contents( This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-bucket-contents-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-bucket-content :param marker: The “marker” for this request, which can be used to paginate through large buckets. Its value should be the value of the @@ -326,7 +326,7 @@ def object_acl_config(self, name=None): This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-bucket-acl :param name: The name of the object for which to retrieve its Access Control List (ACL). Use the Object Storage Bucket Contents List endpoint @@ -363,7 +363,7 @@ def object_acl_config_update(self, acl: ObjectStorageACL, name): This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-object-acl-config-update + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-object-storage-bucket-acl :param acl: The Access Control Level of the bucket, as a canned ACL string. For more fine-grained control of ACLs, use the S3 API directly. @@ -439,7 +439,7 @@ class ObjectStorageCluster(Base): A cluster where Object Storage is available. - API documentation: https://www.linode.com/docs/api/object-storage/#cluster-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-cluster """ api_endpoint = "/object-storage/clusters/{id}" @@ -466,7 +466,7 @@ def buckets_in_cluster(self, *filters): This endpoint is available for convenience. It is recommended that instead you use the more fully-featured S3 API directly. - API Documentation: https://www.linode.com/docs/api/object-storage/#object-storage-buckets-in-cluster-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-bucketin-cluster :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -489,7 +489,7 @@ class ObjectStorageKeys(Base): """ A keypair that allows third-party applications to access Linode Object Storage. - API documentation: https://www.linode.com/docs/api/object-storage/#object-storage-key-view + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-key """ api_endpoint = "/object-storage/keys/{id}" diff --git a/linode_api4/objects/profile.py b/linode_api4/objects/profile.py index 1b9be8305..c37015e84 100644 --- a/linode_api4/objects/profile.py +++ b/linode_api4/objects/profile.py @@ -6,7 +6,7 @@ class AuthorizedApp(Base): """ An application with authorized access to an account. - API Documentation: https://www.linode.com/docs/api/profile/#authorized-app-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile-app """ api_endpoint = "/profile/apps/{id}" @@ -26,7 +26,7 @@ class PersonalAccessToken(Base): """ A Person Access Token associated with a Profile. - API Documentation: https://www.linode.com/docs/api/profile/#personal-access-token-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-personal-access-token """ api_endpoint = "/profile/tokens/{id}" @@ -60,7 +60,7 @@ class Profile(Base): """ A Profile containing information about the current User. - API Documentation: https://www.linode.com/docs/api/profile/#profile-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile """ api_endpoint = "/profile" @@ -88,7 +88,7 @@ def enable_tfa(self): Enables TFA for the token's user. This requies a follow-up request to confirm TFA. Returns the TFA secret that needs to be confirmed. - API Documentation: https://www.linode.com/docs/api/profile/#two-factor-secret-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-tfa-enable :returns: The TFA secret :rtype: str @@ -101,7 +101,7 @@ def confirm_tfa(self, code): """ Confirms TFA for an account. Needs a TFA code generated by enable_tfa - API Documentation: https://www.linode.com/docs/api/profile/#two-factor-authentication-confirmenable + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-tfa-confirm :param code: The Two Factor code you generated with your Two Factor secret. These codes are time-based, so be sure it is current. @@ -120,7 +120,7 @@ def disable_tfa(self): """ Turns off TFA for this user's account. - API Documentation: https://www.linode.com/docs/api/profile/#two-factor-authentication-disable + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-tfa-disable :returns: Returns true if operation was successful :rtype: bool @@ -134,7 +134,7 @@ def grants(self): """ Returns grants for the current user - API Documentation: https://www.linode.com/docs/api/profile/#grants-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile-grants :returns: The grants for the current user :rtype: UserGrants @@ -190,7 +190,7 @@ class SSHKey(Base): """ An SSH Public Key uploaded to your profile for use in Linode Instance deployments. - API Documentation: https://www.linode.com/docs/api/profile/#ssh-key-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ssh-key """ api_endpoint = "/profile/sshkeys/{id}" @@ -207,7 +207,7 @@ class TrustedDevice(Base): """ A Trusted Device for a User. - API Documentation: https://www.linode.com/docs/api/profile/#trusted-device-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-trusted-device """ api_endpoint = "/profile/devices/{id}" @@ -226,7 +226,7 @@ class ProfileLogin(Base): """ A Login object displaying information about a successful account login from this user. - API Documentation: https://www.linode.com/docs/api/profile/#login-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-profile-login """ api_endpoint = "profile/logins/{id}" diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 9356da523..6d8178eff 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -20,7 +20,7 @@ class Region(Base): """ A Region. Regions correspond to individual data centers, each located in a different geographical area. - API Documentation: https://www.linode.com/docs/api/regions/#region-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region """ api_endpoint = "/regions/{id}" @@ -56,7 +56,7 @@ class RegionAvailabilityEntry(JSONObject): """ Represents the availability of a Linode type within a region. - API Documentation: https://www.linode.com/docs/api/regions/#region-availability-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region-availability """ region: str = None diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index 78d1d86d1..f835b3f31 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -19,7 +19,7 @@ class TicketReply(DerivedBase): """ A reply to a Support Ticket. - API Documentation: https://www.linode.com/docs/api/support/#replies-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ticket-replies """ api_endpoint = "/support/tickets/{ticket_id}/replies" @@ -40,7 +40,7 @@ class SupportTicket(Base): """ An objected representing a Linode Support Ticket. - API Documentation: https://www.linode.com/docs/api/support/#replies-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-ticket-replies """ api_endpoint = "/support/tickets/{id}" @@ -117,7 +117,7 @@ def post_reply(self, description): """ Adds a reply to an existing Support Ticket. - API Documentation: https://www.linode.com/docs/api/support/#reply-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ticket-reply :param description: The content of this Support Ticket Reply. :type description: str @@ -146,7 +146,7 @@ def upload_attachment(self, attachment: Union[Path, str]): """ Uploads an attachment to an existing Support Ticket. - API Documentation: https://www.linode.com/docs/api/support/#support-ticket-attachment-create + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ticket-attachment :param attachment: A path to the file to upload as an attachment. :type attachment: str @@ -187,7 +187,7 @@ def support_ticket_close(self): """ Closes a Support Ticket. - API Documentation: https://www.linode.com/docs/api/support/#support-ticket-close + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-close-ticket """ self._client.post("{}/close".format(self.api_endpoint), model=self) diff --git a/linode_api4/objects/tag.py b/linode_api4/objects/tag.py index 856f0d751..4f2e7b1cb 100644 --- a/linode_api4/objects/tag.py +++ b/linode_api4/objects/tag.py @@ -21,7 +21,7 @@ class Tag(Base): A User-defined labels attached to objects in your Account, such as Linodes. Used for specifying and grouping attributes of objects that are relevant to the User. - API Documentation: https://www.linode.com/docs/api/tags/#tags-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-tags """ api_endpoint = "/tags/{label}" @@ -64,7 +64,7 @@ def objects(self): Returns a list of objects with this Tag. This list may contain any taggable object type. - API Documentation: https://www.linode.com/docs/api/tags/#tagged-objects-list + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-tagged-objects :returns: Objects with this Tag :rtype: PaginatedList of objects with this Tag diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 365ceb2d3..6b126cc75 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -7,7 +7,7 @@ class Volume(Base): A single Block Storage Volume. Block Storage Volumes are persistent storage devices that can be attached to a Compute Instance and used to store any type of data. - API Documentation: https://www.linode.com/docs/api/volumes/#volume-view + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-volume """ api_endpoint = "/volumes/{id}" @@ -31,7 +31,7 @@ def attach(self, to_linode, config=None): """ Attaches this Volume to the given Linode. - API Documentation: https://www.linode.com/docs/api/volumes/#volume-attach + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-attach-volume :param to_linode: The ID or object of the Linode to attach the volume to. :type to_linode: Union[Instance, int] @@ -70,7 +70,7 @@ def detach(self): """ Detaches this Volume if it is attached - API Documentation: https://www.linode.com/docs/api/volumes/#volume-detach + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-detach-volume :returns: Returns true if operation was successful :rtype: bool @@ -83,7 +83,7 @@ def resize(self, size): """ Resizes this Volume - API Documentation: https://www.linode.com/docs/api/volumes/#volume-resize + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-resize-volume :param size: The Volume’s size, in GiB. :type size: int @@ -105,7 +105,7 @@ def clone(self, label): """ Clones this volume to a new volume in the same region with the given label - API Documentation: https://www.linode.com/docs/api/volumes/#volume-clone + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-clone-volume :param label: The label for the new volume. :type label: str diff --git a/pyproject.toml b/pyproject.toml index ea96865c1..6720a965c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "linode_api4" -authors = [{ name = "Linode", email = "developers@linode.com" }] +authors = [{ name = "Linode", email = "devs@linode.com" }] description = "The official Python SDK for Linode API v4" readme = "README.rst" requires-python = ">=3.8" From e7f3fc92bddb91470d6b378ece5cbff14eb04a20 Mon Sep 17 00:00:00 2001 From: Lena Garber Date: Tue, 23 Jul 2024 11:45:35 -0400 Subject: [PATCH 226/379] Add LDE LA disclaimer --- linode_api4/groups/linode.py | 1 + linode_api4/objects/linode.py | 1 + 2 files changed, 2 insertions(+) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index c146ce46c..68126d20d 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -274,6 +274,7 @@ def instance_create( :param firewall: The firewall to attach this Linode to. :type firewall: int or Firewall :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index afcf6c2d5..7b3ace39f 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1435,6 +1435,7 @@ def rebuild( the key. :type authorized_keys: list or str :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. :type disk_encryption: InstanceDiskEncryptionType or str :returns: The newly generated password, if one was not provided From 536118ae1707f3b02bcb78aae8340e1040c24f40 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 23 Jul 2024 14:39:50 -0400 Subject: [PATCH 227/379] test: Temporarily disable LDE integration tests (#444) * Temporarily disable LDE tests * various fixes --- test/integration/conftest.py | 3 ++- test/integration/models/linode/test_linode.py | 15 ++++++++++++--- test/integration/models/lke/test_lke.py | 11 ++++++++--- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 9ef80e903..e50ac3abc 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -496,5 +496,6 @@ def create_placement_group_with_linode( @pytest.mark.smoke def pytest_configure(config): config.addinivalue_line( - "markers", "smoke: mark test as part of smoke test suite" + "markers", + "smoke: mark test as part of smoke test suite", ) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 01f3aaa16..afedce93d 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -195,7 +195,11 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - chosen_region = get_region(client, {"Disk Encryption"}) + + # TODO(LDE): Uncomment once LDE is available + # chosen_region = get_region(client, {"Disk Encryption"}) + chosen_region = get_region(client) + label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( @@ -208,14 +212,17 @@ def test_linode_rebuild(test_linode_client): 3, linode.rebuild, "linode/debian10", - disk_encryption=InstanceDiskEncryptionType.disabled, + # TODO(LDE): Uncomment once LDE is available + # disk_encryption=InstanceDiskEncryptionType.disabled, ) wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" assert linode.image.id == "linode/debian10" - assert linode.disk_encryption == InstanceDiskEncryptionType.disabled + + # TODO(LDE): Uncomment once LDE is available + # assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -418,6 +425,8 @@ def test_linode_volumes(linode_with_volume_firewall): assert "test" in volumes[0].label +# TODO(LDE): Remove skip once LDE is available +@pytest.mark.skip("LDE is not currently enabled") @pytest.mark.parametrize( "linode_with_disk_encryption", ["disabled"], indirect=True ) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index ce6700b80..cf46ed850 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -11,7 +11,6 @@ import pytest from linode_api4 import ( - InstanceDiskEncryptionType, LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, @@ -24,7 +23,11 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = get_region(test_linode_client, {"Disk Encryption", "Kubernetes"}) + + # TODO(LDE): Uncomment once LDE is available + # region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) + region = get_region(test_linode_client, {"Kubernetes"}) + node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -98,7 +101,9 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: return {k: v for k, v in p._raw_json.items() if k not in {"nodes"}} assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) - assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + + # TODO(LDE): Uncomment once LDE is available + # assert pool.disk_encryption == InstanceDiskEncryptionType.enabled def test_cluster_dashboard_url_view(lke_cluster): From d04a1a1062bd4fec5be2492a6f407f62c7c912b9 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 25 Jul 2024 10:13:01 -0400 Subject: [PATCH 228/379] Fix error when creating EventError(...) with message=None (#441) --- linode_api4/polling.py | 2 +- test/unit/objects/polling_test.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/linode_api4/polling.py b/linode_api4/polling.py index 6ba02a5b1..947e59e47 100644 --- a/linode_api4/polling.py +++ b/linode_api4/polling.py @@ -13,7 +13,7 @@ class EventError(Exception): def __init__(self, event_id: int, message: Optional[str]): # Edge case, sometimes the message is populated with an empty string - if len(message) < 1: + if message is not None and len(message) < 1: message = None self.event_id = event_id diff --git a/test/unit/objects/polling_test.py b/test/unit/objects/polling_test.py index 7fb7c684f..09c958882 100644 --- a/test/unit/objects/polling_test.py +++ b/test/unit/objects/polling_test.py @@ -328,3 +328,17 @@ def test_wait_for_event_finished_failed( assert err.message == "oh no!" else: raise Exception("Expected event error, got none") + + def test_event_error( + self, + ): + """ + Tests that EventError objects can be constructed and + will be formatted to the correct output. + + Tests for regression of TPT-3060 + """ + + assert str(EventError(123, None)) == "Event 123 failed" + assert str(EventError(123, "")) == "Event 123 failed" + assert str(EventError(123, "foobar")) == "Event 123 failed: foobar" From 0c0e649dc6efea4966f5f2a67eb1210f96b62410 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Thu, 25 Jul 2024 08:19:27 -0700 Subject: [PATCH 229/379] update e2e test submodule repository (#443) --- .github/workflows/e2e-test-pr.yml | 4 ++-- .github/workflows/e2e-test.yml | 4 ++-- .gitmodules | 6 +++--- e2e_scripts | 1 + tod_scripts | 1 - 5 files changed, 8 insertions(+), 8 deletions(-) create mode 160000 e2e_scripts delete mode 160000 tod_scripts diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 9b2113c6e..4d44d48d3 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -103,13 +103,13 @@ jobs: if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python tod_scripts/add_to_xml_test_report.py \ + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" sync - python3 tod_scripts/test_report_upload_script.py "${filename}" + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index abf5fb209..48cb55e13 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -81,13 +81,13 @@ jobs: if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python tod_scripts/add_to_xml_test_report.py \ + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" sync - python3 tod_scripts/test_report_upload_script.py "${filename}" + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} diff --git a/.gitmodules b/.gitmodules index df7dc11d7..1a19a1c1a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "tod_scripts"] - path = tod_scripts - url = https://github.com/linode/TOD-test-report-uploader.git +[submodule "e2e_scripts"] + path = e2e_scripts + url = https://github.com/linode/dx-e2e-test-scripts diff --git a/e2e_scripts b/e2e_scripts new file mode 160000 index 000000000..b56178520 --- /dev/null +++ b/e2e_scripts @@ -0,0 +1 @@ +Subproject commit b56178520fae446a0a4f38df6259deb845efa667 diff --git a/tod_scripts b/tod_scripts deleted file mode 160000 index 41b85dd2c..000000000 --- a/tod_scripts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 41b85dd2c5588b5b343b8ee365b2f4f196cd9a7f From 72e6de48bbf60da0148567ca02e02643f99319c9 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Thu, 1 Aug 2024 09:01:42 -0400 Subject: [PATCH 230/379] Fixed inconsistent value types populated for LKENodePool.nodes (#446) * Updated The LKENodePool(...).nodes attribute to only be populated with LKENodePoolNode objects * Addressed PR comments --- linode_api4/objects/lke.py | 31 +++++++++--- test/unit/objects/lke_test.py | 93 +++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 14de05f45..d5e2c9d79 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -146,14 +146,29 @@ def _populate(self, json): Parse Nodes into more useful LKENodePoolNode objects """ if json is not None and json != {}: - new_nodes = [ - ( - LKENodePoolNode(self._client, c) - if not isinstance(c, dict) - else c - ) - for c in json["nodes"] - ] + new_nodes = [] + for c in json["nodes"]: + if isinstance(c, LKENodePoolNode): + new_nodes.append(c) + elif isinstance(c, dict): + node_id = c.get("id") + if node_id is not None: + new_nodes.append(LKENodePoolNode(self._client, c)) + else: + raise ValueError( + "Node dictionary does not contain 'id' key" + ) + elif isinstance(c, str): + node_details = self._client.get( + LKENodePool.api_endpoint.format( + cluster_id=self.id, id=c + ) + ) + new_nodes.append( + LKENodePoolNode(self._client, node_details) + ) + else: + raise TypeError("Unsupported node type: {}".format(type(c))) json["nodes"] = new_nodes super()._populate(json) diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index f39fb84ae..390aa0de2 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,5 +1,6 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from unittest.mock import MagicMock from linode_api4 import InstanceDiskEncryptionType from linode_api4.objects import ( @@ -9,6 +10,7 @@ LKEClusterControlPlaneOptions, LKENodePool, ) +from linode_api4.objects.lke import LKENodePoolNode class LKETest(ClientBaseCase): @@ -262,3 +264,94 @@ def test_cluster_delete_acl(self): assert m.call_url == "/lke/clusters/18881/control_plane_acl" assert m.method == "get" + + def test_populate_with_node_objects(self): + """ + Tests that LKENodePool correctly handles a list of LKENodePoolNode objects. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node1 = LKENodePoolNode( + self.client, {"id": "node1", "instance_id": 101, "status": "active"} + ) + node2 = LKENodePoolNode( + self.client, + {"id": "node2", "instance_id": 102, "status": "inactive"}, + ) + self.pool._populate({"nodes": [node1, node2]}) + + self.assertEqual(len(self.pool.nodes), 2) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node1") + self.assertEqual(self.pool.nodes[1].id, "node2") + + def test_populate_with_node_dicts(self): + """ + Tests that LKENodePool correctly handles a list of node dictionaries. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node_dict1 = {"id": "node3", "instance_id": 103, "status": "pending"} + node_dict2 = {"id": "node4", "instance_id": 104, "status": "failed"} + self.pool._populate({"nodes": [node_dict1, node_dict2]}) + + self.assertEqual(len(self.pool.nodes), 2) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node3") + self.assertEqual(self.pool.nodes[1].id, "node4") + + def test_populate_with_node_ids(self): + """ + Tests that LKENodePool correctly handles a list of node IDs. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node_id1 = "node5" + node_id2 = "node6" + # Mock instances creation + self.client.get = MagicMock( + side_effect=[ + {"id": "node5", "instance_id": 105, "status": "active"}, + {"id": "node6", "instance_id": 106, "status": "inactive"}, + ] + ) + self.pool._populate({"nodes": [node_id1, node_id2]}) + + self.assertEqual(len(self.pool.nodes), 2) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node5") + self.assertEqual(self.pool.nodes[1].id, "node6") + + def test_populate_with_mixed_types(self): + """ + Tests that LKENodePool correctly handles a mixed list of node objects, dicts, and IDs. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node1 = LKENodePoolNode( + self.client, {"id": "node7", "instance_id": 107, "status": "active"} + ) + node_dict = {"id": "node8", "instance_id": 108, "status": "inactive"} + node_id = "node9" + # Mock instances creation + self.client.get = MagicMock( + side_effect=[ + {"id": "node9", "instance_id": 109, "status": "pending"} + ] + ) + self.pool._populate({"nodes": [node1, node_dict, node_id]}) + + self.assertEqual(len(self.pool.nodes), 3) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[2], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node7") + self.assertEqual(self.pool.nodes[1].id, "node8") + self.assertEqual(self.pool.nodes[2].id, "node9") From ebf5cdd3779d0c58f72b08267c5d277b5baccf81 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 6 Aug 2024 14:27:36 -0400 Subject: [PATCH 231/379] doc: Replace outdated API documentation TODOs with URLs (#447) * Replace outdated documentation TODOs * Apply to child accounts --- linode_api4/groups/account.py | 2 +- linode_api4/groups/placement.py | 2 +- linode_api4/groups/vpc.py | 6 +++--- linode_api4/objects/account.py | 6 +++--- linode_api4/objects/linode.py | 14 +++++++------- linode_api4/objects/placement.py | 4 +--- linode_api4/objects/vpc.py | 8 ++++---- 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 88f53ed09..c2c69c624 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -504,7 +504,7 @@ def child_accounts(self, *filters): NOTE: Parent/Child related features may not be generally available. - API doc: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-child-accounts :returns: a list of all child accounts. :rtype: PaginatedList of ChildAccount diff --git a/linode_api4/groups/placement.py b/linode_api4/groups/placement.py index e56970346..b1fa0f32b 100644 --- a/linode_api4/groups/placement.py +++ b/linode_api4/groups/placement.py @@ -20,7 +20,7 @@ def groups(self, *filters): groups = client.placement.groups(PlacementGroup.label == "test") - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-placement-groups :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index f3f4f27b6..fa8066cea 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -16,7 +16,7 @@ def __call__(self, *filters) -> PaginatedList: vpcs = client.vpcs() - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpcs :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -38,7 +38,7 @@ def create( """ Creates a new VPC under your Linode account. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-vpc :param label: The label of the newly created VPC. :type label: str @@ -90,7 +90,7 @@ def ips(self, *filters) -> PaginatedList: vpc_ips = client.vpcs.ips() - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpcs-ips :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 31e3cf33d..9365a9127 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -62,7 +62,7 @@ class ChildAccount(Account): NOTE: Parent/Child related features may not be generally available. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-child-account """ api_endpoint = "/account/child-accounts/{euuid}" @@ -70,9 +70,9 @@ class ChildAccount(Account): def create_token(self, **kwargs): """ - Create a ephemeral token for accessing the child account. + Create an ephemeral token for accessing the child account. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-child-account-token """ resp = self._client.post( "{}/token".format(self.api_endpoint), diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 0e43f1567..39564200f 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -294,7 +294,7 @@ class NetworkInterface(DerivedBase): NOTE: This class cannot be used for the `interfaces` attribute on Config POST and PUT requests. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config-interface """ api_endpoint = ( @@ -369,7 +369,7 @@ class ConfigInterface(JSONObject): If you would like to access a config interface directly, consider using :any:`NetworkInterface`. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config-interface """ purpose: str = "public" @@ -462,7 +462,7 @@ def network_interfaces(self): This differs from the `interfaces` field as each NetworkInterface object is treated as its own API object. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config-interfaces """ return [ @@ -523,7 +523,7 @@ def interface_create_public(self, primary=False) -> NetworkInterface: """ Creates a public interface for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interface :param primary: Whether this interface is a primary interface. :type primary: bool @@ -540,7 +540,7 @@ def interface_create_vlan( """ Creates a VLAN interface for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interface :param label: The label of the VLAN to associate this interface with. :type label: str @@ -569,7 +569,7 @@ def interface_create_vpc( """ Creates a VPC interface for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interface :param subnet: The VPC subnet to associate this interface with. :type subnet: int or VPCSubnet @@ -605,7 +605,7 @@ def interface_reorder(self, interfaces: List[Union[int, NetworkInterface]]): """ Change the order of the interfaces for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interfaces :param interfaces: A list of interfaces in the desired order. :type interfaces: List of str or NetworkInterface diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py index 616c9061f..aa894af33 100644 --- a/linode_api4/objects/placement.py +++ b/linode_api4/objects/placement.py @@ -41,7 +41,7 @@ class PlacementGroup(Base): A VM Placement Group, defining the affinity policy for Linodes created in a region. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-placement-group """ api_endpoint = "/placement/groups/{id}" @@ -66,8 +66,6 @@ def assign( :param linodes: A list of Linodes to assign to the Placement Group. :type linodes: List[Union[Instance, int]] - :param compliant_only: TODO - :type compliant_only: bool """ params = { "linodes": [ diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index e44eebcdc..456bdcfbc 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -23,7 +23,7 @@ class VPCSubnet(DerivedBase): """ An instance of a VPC subnet. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpc-subnet """ api_endpoint = "/vpcs/{vpc_id}/subnets/{id}" @@ -44,7 +44,7 @@ class VPC(Base): """ An instance of a VPC. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpc """ api_endpoint = "/vpcs/{id}" @@ -68,7 +68,7 @@ def subnet_create( """ Creates a new Subnet object under this VPC. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-vpc-subnet :param label: The label of this subnet. :type label: str @@ -104,7 +104,7 @@ def ips(self) -> PaginatedList: """ Get all the IP addresses under this VPC. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpc-ips :returns: A list of VPCIPAddresses the acting user can access. :rtype: PaginatedList of VPCIPAddress From cea7eb29c994b2a5329f413707ccb78808a1504a Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Fri, 9 Aug 2024 11:33:38 -0400 Subject: [PATCH 232/379] Project: Image Services Gen2 (#445) * new: Support Image Gen2 functionalities (#428) * image gen2 * nit * lint * fix strenum import * sort imports * add int test * address comments * rename * added LA disclamier; modified replication test case * use random region in test_linode_client with caps; use stable regions for image gen2 * fix int test * fix lint * replace todo with doc link --- linode_api4/groups/image.py | 51 +++++++++++---- linode_api4/linode_client.py | 29 +++++++-- linode_api4/objects/image.py | 60 ++++++++++++++++- test/fixtures/images.json | 24 ++++++- test/fixtures/images_private_123_regions.json | 29 +++++++++ test/fixtures/images_upload.json | 3 +- test/integration/conftest.py | 7 +- .../linode_client/test_linode_client.py | 26 +++++--- test/integration/models/image/test_image.py | 65 +++++++++++++++---- test/unit/linode_client_test.py | 5 +- test/unit/objects/image_test.py | 31 ++++++++- 11 files changed, 280 insertions(+), 50 deletions(-) create mode 100644 test/fixtures/images_private_123_regions.json diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index d22363af3..451a73d19 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -1,10 +1,10 @@ -from typing import BinaryIO, Tuple +from typing import BinaryIO, List, Optional, Tuple, Union import requests from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Image +from linode_api4.objects import Base, Disk, Image from linode_api4.util import drop_null_keys @@ -29,14 +29,21 @@ def __call__(self, *filters): """ return self.client._get_and_filter(Image, *filters) - def create(self, disk, label=None, description=None, cloud_init=False): + def create( + self, + disk: Union[Disk, int], + label: str = None, + description: str = None, + cloud_init: bool = False, + tags: Optional[List[str]] = None, + ): """ Creates a new Image from a disk you own. API Documentation: https://techdocs.akamai.com/linode-api/reference/post-image :param disk: The Disk to imagize. - :type disk: Disk or int + :type disk: Union[Disk, int] :param label: The label for the resulting Image (defaults to the disk's label. :type label: str @@ -44,24 +51,23 @@ def create(self, disk, label=None, description=None, cloud_init=False): :type description: str :param cloud_init: Whether this Image supports cloud-init. :type cloud_init: bool + :param tags: A list of customized tags of this new Image. + :type tags: Optional[List[str]] :returns: The new Image. :rtype: Image """ params = { "disk_id": disk.id if issubclass(type(disk), Base) else disk, + "label": label, + "description": description, + "tags": tags, } - if label is not None: - params["label"] = label - - if description is not None: - params["description"] = description - if cloud_init: params["cloud_init"] = cloud_init - result = self.client.post("/images", data=params) + result = self.client.post("/images", data=drop_null_keys(params)) if not "id" in result: raise UnexpectedResponseError( @@ -78,6 +84,7 @@ def create_upload( region: str, description: str = None, cloud_init: bool = False, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ Creates a new Image and returns the corresponding upload URL. @@ -92,11 +99,18 @@ def create_upload( :type description: str :param cloud_init: Whether this Image supports cloud-init. :type cloud_init: bool + :param tags: A list of customized tags of this Image. + :type tags: Optional[List[str]] :returns: A tuple containing the new image and the image upload URL. :rtype: (Image, str) """ - params = {"label": label, "region": region, "description": description} + params = { + "label": label, + "region": region, + "description": description, + "tags": tags, + } if cloud_init: params["cloud_init"] = cloud_init @@ -114,7 +128,12 @@ def create_upload( return Image(self.client, result_image["id"], result_image), result_url def upload( - self, label: str, region: str, file: BinaryIO, description: str = None + self, + label: str, + region: str, + file: BinaryIO, + description: str = None, + tags: Optional[List[str]] = None, ) -> Image: """ Creates and uploads a new image. @@ -128,12 +147,16 @@ def upload( :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb"). :param description: The description for the new Image. :type description: str + :param tags: A list of customized tags of this Image. + :type tags: Optional[List[str]] :returns: The resulting image. :rtype: Image """ - image, url = self.create_upload(label, region, description=description) + image, url = self.create_upload( + label, region, description=description, tags=tags + ) requests.put( url, diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 8c7819119..66e3d45fe 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -3,7 +3,7 @@ import json import logging from importlib.metadata import version -from typing import BinaryIO, Tuple +from typing import BinaryIO, List, Tuple from urllib import parse import requests @@ -378,15 +378,21 @@ def __setattr__(self, key, value): super().__setattr__(key, value) - def image_create(self, disk, label=None, description=None): + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. Please use :meth:`LinodeClient.images.create(...) <.ImageGroup.create>` for all new projects. """ - return self.images.create(disk, label=label, description=description) + return self.images.create( + disk, label=label, description=description, tags=tags + ) def image_create_upload( - self, label: str, region: str, description: str = None + self, + label: str, + region: str, + description: str = None, + tags: List[str] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -394,16 +400,25 @@ def image_create_upload( for all new projects. """ - return self.images.create_upload(label, region, description=description) + return self.images.create_upload( + label, region, description=description, tags=tags + ) def image_upload( - self, label: str, region: str, file: BinaryIO, description: str = None + self, + label: str, + region: str, + file: BinaryIO, + description: str = None, + tags: List[str] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. Please use :meth:`LinodeClient.images.upload(...) <.ImageGroup.upload>` for all new projects. """ - return self.images.upload(label, region, file, description=description) + return self.images.upload( + label, region, file, description=description, tags=tags + ) def nodebalancer_create(self, region, **kwargs): """ diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 2317dd20d..b2c413f86 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -1,4 +1,31 @@ -from linode_api4.objects import Base, Property +from dataclasses import dataclass +from typing import List, Union + +from linode_api4.objects import Base, Property, Region +from linode_api4.objects.serializable import JSONObject, StrEnum + + +class ReplicationStatus(StrEnum): + """ + The Enum class represents image replication status. + """ + + pending_replication = "pending replication" + pending_deletion = "pending deletion" + available = "available" + creating = "creating" + pending = "pending" + replicating = "replicating" + + +@dataclass +class ImageRegion(JSONObject): + """ + The region and status of an image replica. + """ + + region: str = "" + status: ReplicationStatus = None class Image(Base): @@ -28,4 +55,35 @@ class Image(Base): "capabilities": Property( unordered=True, ), + "tags": Property(mutable=True, unordered=True), + "total_size": Property(), + "regions": Property(json_object=ImageRegion, unordered=True), } + + def replicate(self, regions: Union[List[str], List[Region]]): + """ + Replicate the image to other regions. + + Note: Image replication may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-replicate-image + + :param regions: A list of regions that the customer wants to replicate this image in. + At least one valid region is required and only core regions allowed. + Existing images in the regions not passed will be removed. + :type regions: List[str] + """ + params = { + "regions": [ + region.id if isinstance(region, Region) else region + for region in regions + ] + } + + result = self._client.post( + "{}/regions".format(self.api_endpoint), model=self, data=params + ) + + # The replicate endpoint returns the updated Image, so we can use this + # as an opportunity to refresh the object + self._populate(result) diff --git a/test/fixtures/images.json b/test/fixtures/images.json index c33141527..357110bc7 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -18,7 +18,15 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "tags": ["tests"], + "total_size": 1100, + "regions": [ + { + "region": "us-east", + "status": "available" + } + ] }, { "created": "2017-01-01T00:01:01", @@ -35,7 +43,19 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "tags": ["tests"], + "total_size": 3000, + "regions": [ + { + "region": "us-east", + "status": "available" + }, + { + "region": "us-mia", + "status": "pending" + } + ] }, { "created": "2017-01-01T00:01:01", diff --git a/test/fixtures/images_private_123_regions.json b/test/fixtures/images_private_123_regions.json new file mode 100644 index 000000000..5540fc116 --- /dev/null +++ b/test/fixtures/images_private_123_regions.json @@ -0,0 +1,29 @@ +{ + "created": "2017-08-20T14:01:01", + "description": null, + "deprecated": false, + "status": "available", + "created_by": "testguy", + "id": "private/123", + "label": "Gold Master", + "size": 650, + "is_public": false, + "type": "manual", + "vendor": null, + "eol": "2026-07-01T04:00:00", + "expiry": "2026-08-01T04:00:00", + "updated": "2020-07-01T04:00:00", + "capabilities": ["cloud-init"], + "tags": ["tests"], + "total_size": 1300, + "regions": [ + { + "region": "us-east", + "status": "available" + }, + { + "region": "us-west", + "status": "pending replication" + } + ] +} \ No newline at end of file diff --git a/test/fixtures/images_upload.json b/test/fixtures/images_upload.json index 60f726464..893270130 100644 --- a/test/fixtures/images_upload.json +++ b/test/fixtures/images_upload.json @@ -14,7 +14,8 @@ "type": "manual", "updated": "2021-08-14T22:44:02", "vendor": "Debian", - "capabilities": ["cloud-init"] + "capabilities": ["cloud-init"], + "tags": ["test_tag", "test2"] }, "upload_to": "https://linode.com/" } \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index e50ac3abc..220cd4093 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -34,7 +34,9 @@ def get_random_label(): return label -def get_region(client: LinodeClient, capabilities: Set[str] = None): +def get_region( + client: LinodeClient, capabilities: Set[str] = None, site_type: str = None +): region_override = os.environ.get(ENV_REGION_OVERRIDE) # Allow overriding the target test region @@ -48,6 +50,9 @@ def get_region(client: LinodeClient, capabilities: Set[str] = None): v for v in regions if set(capabilities).issubset(v.capabilities) ] + if site_type is not None: + regions = [v for v in regions if v.site_type == site_type] + return random.choice(regions) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index df634cf06..92224abd4 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -1,5 +1,6 @@ import re import time +from test.integration.conftest import get_region from test.integration.helpers import get_test_label import pytest @@ -11,8 +12,10 @@ @pytest.fixture(scope="session") def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] # us-ord (Chicago) + chosen_region = get_region( + client, {"Kubernetes", "NodeBalancers"}, "core" + ).id + label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -90,14 +93,18 @@ def test_image_create(setup_client_and_linode): label = get_test_label() description = "Test description" + tags = ["test"] usable_disk = [v for v in linode.disks if v.filesystem != "swap"] image = client.image_create( - disk=usable_disk[0].id, label=label, description=description + disk=usable_disk[0].id, label=label, description=description, tags=tags ) assert image.label == label assert image.description == description + assert image.tags == tags + # size and total_size are the same because this image is not replicated + assert image.size == image.total_size def test_fails_to_create_image_with_non_existing_disk_id( @@ -215,7 +222,7 @@ def test_get_account_settings(test_linode_client): assert account_settings._populated == True assert re.search( - "'network_helper':True|False", str(account_settings._raw_json) + "'network_helper':\s*(True|False)", str(account_settings._raw_json) ) @@ -225,8 +232,7 @@ def test_get_account_settings(test_linode_client): # LinodeGroupTests def test_create_linode_instance_without_image(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Linodes"}, "core").id label = get_test_label() linode_instance = client.linode.instance_create( @@ -250,8 +256,7 @@ def test_create_linode_instance_with_image(setup_client_and_linode): def test_create_linode_with_interfaces(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Vlans", "Linodes"}).id label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -323,7 +328,7 @@ def test_cluster_create_with_api_objects(test_linode_client): client = test_linode_client node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] - region = client.regions().first() + region = get_region(client, {"Kubernetes"}) node_pools = client.lke.node_pool(node_type, 3) label = get_test_label() @@ -340,10 +345,11 @@ def test_cluster_create_with_api_objects(test_linode_client): def test_fails_to_create_cluster_with_invalid_version(test_linode_client): invalid_version = "a.12" client = test_linode_client + region = get_region(client, {"Kubernetes"}).id try: cluster = client.lke.cluster_create( - "us-ord", + region, "example-cluster", {"type": "g6-standard-1", "count": 3}, invalid_version, diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index a622b355e..5c4025dfc 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -1,20 +1,24 @@ from io import BytesIO +from test.integration.conftest import get_region from test.integration.helpers import ( delete_instance_with_test_kw, get_test_label, ) +import polling import pytest from linode_api4.objects import Image @pytest.fixture(scope="session") -def image_upload(test_linode_client): +def image_upload_url(test_linode_client): label = get_test_label() + "_image" + region = get_region(test_linode_client, site_type="core") + test_linode_client.image_create_upload( - label, "us-east", "integration test image upload" + label, region.id, "integration test image upload" ) image = test_linode_client.images()[0] @@ -26,26 +30,63 @@ def image_upload(test_linode_client): delete_instance_with_test_kw(images) -@pytest.mark.smoke -def test_get_image(test_linode_client, image_upload): - image = test_linode_client.load(Image, image_upload.id) - - assert image.label == image_upload.label - - -def test_image_create_upload(test_linode_client): +@pytest.fixture(scope="session") +def test_uploaded_image(test_linode_client): test_image_content = ( b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) label = get_test_label() + "_image" + image = test_linode_client.image_upload( label, - "us-ord", + "us-east", BytesIO(test_image_content), description="integration test image upload", + tags=["tests"], ) - assert image.label == label + yield image + + image.delete() + + +@pytest.mark.smoke +def test_get_image(test_linode_client, image_upload_url): + image = test_linode_client.load(Image, image_upload_url.id) + + assert image.label == image_upload_url.label + + +def test_image_create_upload(test_linode_client, test_uploaded_image): + image = test_linode_client.load(Image, test_uploaded_image.id) + + assert image.label == test_uploaded_image.label assert image.description == "integration test image upload" + assert image.tags[0] == "tests" + + +@pytest.mark.smoke +def test_image_replication(test_linode_client, test_uploaded_image): + image = test_linode_client.load(Image, test_uploaded_image.id) + + # wait for image to be available for replication + def poll_func() -> bool: + image._api_get() + return image.status in {"available"} + + try: + polling.poll( + poll_func, + step=10, + timeout=250, + ) + except polling.TimeoutException: + print("failed to wait for image status: timeout period expired.") + + # image replication works stably in these two regions + image.replicate(["us-east", "eu-west"]) + + assert image.label == test_uploaded_image.label + assert len(image.regions) == 2 diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 081b27d09..84c003e97 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -127,7 +127,9 @@ def test_image_create(self): Tests that an Image can be created successfully """ with self.mock_post("images/private/123") as m: - i = self.client.image_create(654, "Test-Image", "This is a test") + i = self.client.image_create( + 654, "Test-Image", "This is a test", ["test"] + ) self.assertIsNotNone(i) self.assertEqual(i.id, "private/123") @@ -141,6 +143,7 @@ def test_image_create(self): "disk_id": 654, "label": "Test-Image", "description": "This is a test", + "tags": ["test"], }, ) diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index 983192e69..d4851e777 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -4,7 +4,7 @@ from typing import BinaryIO from unittest.mock import patch -from linode_api4.objects import Image +from linode_api4.objects import Image, Region # A minimal gzipped image that will be accepted by the API TEST_IMAGE_CONTENT = ( @@ -51,6 +51,11 @@ def test_get_image(self): datetime(year=2020, month=7, day=1, hour=4, minute=0, second=0), ) + self.assertEqual(image.tags[0], "tests") + self.assertEqual(image.total_size, 1100) + self.assertEqual(image.regions[0].region, "us-east") + self.assertEqual(image.regions[0].status, "available") + def test_image_create_upload(self): """ Test that an image upload URL can be created successfully. @@ -61,6 +66,7 @@ def test_image_create_upload(self): "Realest Image Upload", "us-southeast", description="very real image upload.", + tags=["test_tag", "test2"], ) self.assertEqual(m.call_url, "/images/upload") @@ -71,6 +77,7 @@ def test_image_create_upload(self): "label": "Realest Image Upload", "region": "us-southeast", "description": "very real image upload.", + "tags": ["test_tag", "test2"], }, ) @@ -78,6 +85,8 @@ def test_image_create_upload(self): self.assertEqual(image.label, "Realest Image Upload") self.assertEqual(image.description, "very real image upload.") self.assertEqual(image.capabilities[0], "cloud-init") + self.assertEqual(image.tags[0], "test_tag") + self.assertEqual(image.tags[1], "test2") self.assertEqual(url, "https://linode.com/") @@ -96,11 +105,14 @@ def put_mock(url: str, data: BinaryIO = None, **kwargs): "us-southeast", BytesIO(TEST_IMAGE_CONTENT), description="very real image upload.", + tags=["test_tag", "test2"], ) self.assertEqual(image.id, "private/1337") self.assertEqual(image.label, "Realest Image Upload") self.assertEqual(image.description, "very real image upload.") + self.assertEqual(image.tags[0], "test_tag") + self.assertEqual(image.tags[1], "test2") def test_image_create_cloud_init(self): """ @@ -131,3 +143,20 @@ def test_image_create_upload_cloud_init(self): ) self.assertTrue(m.call_data["cloud_init"]) + + def test_image_replication(self): + """ + Test that image can be replicated. + """ + + replication_url = "/images/private/123/regions" + regions = ["us-east", Region(self.client, "us-west")] + with self.mock_post(replication_url) as m: + image = Image(self.client, "private/123") + image.replicate(regions) + + self.assertEqual(replication_url, m.call_url) + self.assertEqual( + m.call_data, + {"regions": ["us-east", "us-west"]}, + ) From 88699ae32916e40f06a08a79890d78d19672d1e3 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 14 Aug 2024 14:03:08 -0400 Subject: [PATCH 233/379] new: Add support for LKE node pool labels & taints (#448) * WIP * fix populate errors * Finish unit tests * Add integration test * Update linode_api4/objects/lke.py Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --------- Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --- linode_api4/groups/lke.py | 51 ++--- linode_api4/objects/base.py | 33 ++- linode_api4/objects/lke.py | 94 +++++--- .../lke_clusters_18881_pools_456.json | 11 + test/integration/models/lke/test_lke.py | 90 +++++++- test/unit/objects/lke_test.py | 204 +++++++++++++++--- 6 files changed, 388 insertions(+), 95 deletions(-) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index 175a730ca..f2bc5a388 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -3,13 +3,13 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - Base, - JSONObject, KubeVersion, LKECluster, LKEClusterControlPlaneOptions, + Type, drop_null_keys, ) +from linode_api4.objects.base import _flatten_request_body_recursive class LKEGroup(Group): @@ -107,41 +107,22 @@ def cluster_create( :returns: The new LKE Cluster :rtype: LKECluster """ - pools = [] - if not isinstance(node_pools, list): - node_pools = [node_pools] - - for c in node_pools: - if isinstance(c, dict): - new_pool = { - "type": ( - c["type"].id - if "type" in c and issubclass(type(c["type"]), Base) - else c.get("type") - ), - "count": c.get("count"), - } - - pools += [new_pool] params = { "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "node_pools": pools, - "k8s_version": ( - kube_version.id - if issubclass(type(kube_version), Base) - else kube_version - ), - "control_plane": ( - control_plane.dict - if issubclass(type(control_plane), JSONObject) - else control_plane + "region": region, + "k8s_version": kube_version, + "node_pools": ( + node_pools if isinstance(node_pools, list) else [node_pools] ), + "control_plane": control_plane, } params.update(kwargs) - result = self.client.post("/lke/clusters", data=drop_null_keys(params)) + result = self.client.post( + "/lke/clusters", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if "id" not in result: raise UnexpectedResponseError( @@ -150,7 +131,7 @@ def cluster_create( return LKECluster(self.client, result["id"], result) - def node_pool(self, node_type, node_count): + def node_pool(self, node_type: Union[Type, str], node_count: int, **kwargs): """ Returns a dict that is suitable for passing into the `node_pools` array of :any:`cluster_create`. This is a convenience method, and need not be @@ -160,11 +141,17 @@ def node_pool(self, node_type, node_count): :type node_type: Type or str :param node_count: The number of nodes to create in this node pool. :type node_count: int + :param kwargs: Other attributes to create this node pool with. + :type kwargs: Any :returns: A dict describing the desired node pool. :rtype: dict """ - return { + result = { "type": node_type, "count": node_count, } + + result.update(kwargs) + + return result diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index abee4cdaa..6c9b1bece 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -277,6 +277,8 @@ def save(self, force=True) -> bool: ): data[key] = None + # Ensure we serialize any values that may not be already serialized + data = _flatten_request_body_recursive(data) else: data = self._serialize() @@ -343,10 +345,7 @@ def _serialize(self): # Resolve the underlying IDs of results for k, v in result.items(): - if isinstance(v, Base): - result[k] = v.id - elif isinstance(v, MappedObject) or issubclass(type(v), JSONObject): - result[k] = v.dict + result[k] = _flatten_request_body_recursive(v) return result @@ -502,3 +501,29 @@ def make_instance(cls, id, client, parent_id=None, json=None): :returns: A new instance of this type, populated with json """ return Base.make(id, client, cls, parent_id=parent_id, json=json) + + +def _flatten_request_body_recursive(data: Any) -> Any: + """ + This is a helper recursively flatten the given data for use in an API request body. + + NOTE: This helper does NOT raise an error if an attribute is + not known to be JSON serializable. + + :param data: Arbitrary data to flatten. + :return: The serialized data. + """ + + if isinstance(data, dict): + return {k: _flatten_request_body_recursive(v) for k, v in data.items()} + + if isinstance(data, list): + return [_flatten_request_body_recursive(v) for v in data] + + if isinstance(data, Base): + return data.id + + if isinstance(data, MappedObject) or issubclass(type(data), JSONObject): + return data.dict + + return data diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index d5e2c9d79..7889c9c07 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -29,6 +29,18 @@ class KubeVersion(Base): } +@dataclass +class LKENodePoolTaint(JSONObject): + """ + LKENodePoolTaint represents the structure of a single taint that can be + applied to a node pool. + """ + + key: Optional[str] = None + value: Optional[str] = None + effect: Optional[str] = None + + @dataclass class LKEClusterControlPlaneACLAddressesOptions(JSONObject): """ @@ -139,37 +151,51 @@ class LKENodePool(DerivedBase): ), # this is formatted in _populate below "autoscaler": Property(mutable=True), "tags": Property(mutable=True, unordered=True), + "labels": Property(mutable=True), + "taints": Property(mutable=True), } + def _parse_raw_node( + self, raw_node: Union[LKENodePoolNode, dict, str] + ) -> LKENodePoolNode: + """ + Builds a list of LKENodePoolNode objects given a node pool response's JSON. + """ + if isinstance(raw_node, LKENodePoolNode): + return raw_node + + if isinstance(raw_node, dict): + node_id = raw_node.get("id") + if node_id is None: + raise ValueError("Node dictionary does not contain 'id' key") + + return LKENodePoolNode(self._client, raw_node) + + if isinstance(raw_node, str): + return self._client.load( + LKENodePoolNode, target_id=raw_node, target_parent_id=self.id + ) + + raise TypeError("Unsupported node type: {}".format(type(raw_node))) + def _populate(self, json): """ Parse Nodes into more useful LKENodePoolNode objects """ + if json is not None and json != {}: - new_nodes = [] - for c in json["nodes"]: - if isinstance(c, LKENodePoolNode): - new_nodes.append(c) - elif isinstance(c, dict): - node_id = c.get("id") - if node_id is not None: - new_nodes.append(LKENodePoolNode(self._client, c)) - else: - raise ValueError( - "Node dictionary does not contain 'id' key" - ) - elif isinstance(c, str): - node_details = self._client.get( - LKENodePool.api_endpoint.format( - cluster_id=self.id, id=c - ) - ) - new_nodes.append( - LKENodePoolNode(self._client, node_details) - ) - else: - raise TypeError("Unsupported node type: {}".format(type(c))) - json["nodes"] = new_nodes + json["nodes"] = [ + self._parse_raw_node(node) for node in json.get("nodes", []) + ] + + json["taints"] = [ + ( + LKENodePoolTaint.from_json(taint) + if not isinstance(taint, LKENodePoolTaint) + else taint + ) + for taint in json.get("taints", []) + ] super()._populate(json) @@ -302,7 +328,14 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: return LKEClusterControlPlaneACL.from_json(self._control_plane_acl) - def node_pool_create(self, node_type, node_count, **kwargs): + def node_pool_create( + self, + node_type: Union[Type, str], + node_count: int, + labels: Dict[str, str] = None, + taints: List[Union[LKENodePoolTaint, Dict[str, Any]]] = None, + **kwargs, + ): """ Creates a new :any:`LKENodePool` for this cluster. @@ -312,6 +345,10 @@ def node_pool_create(self, node_type, node_count, **kwargs): :type node_type: :any:`Type` or str :param node_count: The number of nodes to create in this pool. :type node_count: int + :param labels: A dict mapping labels to their values to apply to this pool. + :type labels: Dict[str, str] + :param taints: A list of taints to apply to this pool. + :type taints: List of :any:`LKENodePoolTaint` or dict :param kwargs: Any other arguments to pass to the API. See the API docs for possible values. @@ -322,6 +359,13 @@ def node_pool_create(self, node_type, node_count, **kwargs): "type": node_type, "count": node_count, } + + if labels is not None: + params["labels"] = labels + + if taints is not None: + params["taints"] = taints + params.update(kwargs) result = self._client.post( diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index 225023d5d..f904b9c95 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -23,6 +23,17 @@ "example tag", "another example" ], + "taints": [ + { + "key": "foo", + "value": "bar", + "effect": "NoSchedule" + } + ], + "labels": { + "foo": "bar", + "bar": "foo" + }, "type": "g6-standard-4", "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index cf46ed850..2f659f9a7 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -16,7 +16,7 @@ LKEClusterControlPlaneOptions, ) from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool +from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolTaint @pytest.fixture(scope="session") @@ -68,6 +68,43 @@ def lke_cluster_with_acl(test_linode_client): cluster.delete() +# NOTE: This needs to be function-scoped because it is mutated in a test below. +@pytest.fixture(scope="function") +def lke_cluster_with_labels_and_taints(test_linode_client): + node_type = test_linode_client.linode.types()[1] # g6-standard-1 + version = test_linode_client.lke.versions()[0] + + region = get_region(test_linode_client, {"Kubernetes"}) + + node_pools = test_linode_client.lke.node_pool( + node_type, + 3, + labels={ + "foo.example.com/test": "bar", + "foo.example.com/test2": "test", + }, + taints=[ + LKENodePoolTaint( + key="foo.example.com/test", value="bar", effect="NoSchedule" + ), + { + "key": "foo.example.com/test2", + "value": "cool", + "effect": "NoExecute", + }, + ], + ) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, label, node_pools, version + ) + + yield cluster + + cluster.delete() + + def get_cluster_status(cluster: LKECluster, status: str): return cluster._raw_json["status"] == status @@ -232,3 +269,54 @@ def test_lke_cluster_acl(lke_cluster_with_acl): cluster.control_plane_acl_delete() assert not cluster.control_plane_acl.enabled + + +def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): + pool = lke_cluster_with_labels_and_taints.pools[0] + + assert vars(pool.labels) == { + "foo.example.com/test": "bar", + "foo.example.com/test2": "test", + } + + assert ( + LKENodePoolTaint( + key="foo.example.com/test", value="bar", effect="NoSchedule" + ) + in pool.taints + ) + + assert ( + LKENodePoolTaint( + key="foo.example.com/test2", value="cool", effect="NoExecute" + ) + in pool.taints + ) + + updated_labels = { + "foo.example.com/test": "bar", + "foo.example.com/test2": "cool", + } + + updated_taints = [ + LKENodePoolTaint( + key="foo.example.com/test", value="bar", effect="NoSchedule" + ), + { + "key": "foo.example.com/test2", + "value": "cool", + "effect": "NoExecute", + }, + ] + + pool.labels = updated_labels + pool.taints = updated_taints + + pool.save() + + # Invalidate the pool so we can assert on the refreshed values + pool.invalidate() + + assert vars(pool.labels) == updated_labels + assert updated_taints[0] in pool.taints + assert LKENodePoolTaint.from_json(updated_taints[1]) in pool.taints diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 390aa0de2..100f36487 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -10,7 +10,7 @@ LKEClusterControlPlaneOptions, LKENodePool, ) -from linode_api4.objects.lke import LKENodePoolNode +from linode_api4.objects.lke import LKENodePoolNode, LKENodePoolTaint class LKETest(ClientBaseCase): @@ -47,16 +47,23 @@ def test_get_pool(self): pool = LKENodePool(self.client, 456, 18881) - self.assertEqual(pool.id, 456) - self.assertEqual(pool.cluster_id, 18881) - self.assertEqual(pool.type.id, "g6-standard-4") - self.assertEqual( - pool.disk_encryption, InstanceDiskEncryptionType.enabled - ) - self.assertIsNotNone(pool.disks) - self.assertIsNotNone(pool.nodes) - self.assertIsNotNone(pool.autoscaler) - self.assertIsNotNone(pool.tags) + assert pool.id == 456 + assert pool.cluster_id == 18881 + assert pool.type.id == "g6-standard-4" + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + + assert pool.disks is not None + assert pool.nodes is not None + assert pool.autoscaler is not None + assert pool.tags is not None + + assert pool.labels.foo == "bar" + assert pool.labels.bar == "foo" + + assert isinstance(pool.taints[0], LKENodePoolTaint) + assert pool.taints[0].key == "foo" + assert pool.taints[0].value == "bar" + assert pool.taints[0].effect == "NoSchedule" def test_cluster_dashboard_url_view(self): """ @@ -265,6 +272,122 @@ def test_cluster_delete_acl(self): assert m.call_url == "/lke/clusters/18881/control_plane_acl" assert m.method == "get" + def test_lke_node_pool_update(self): + """ + Tests that an LKE Node Pool can be properly updated. + """ + pool = LKENodePool(self.client, 456, 18881) + + pool.tags = ["foobar"] + pool.count = 5 + pool.autoscaler = { + "enabled": True, + "min": 2, + "max": 10, + } + pool.labels = {"updated-key": "updated-value"} + pool.taints = [ + LKENodePoolTaint( + key="updated-key", value="updated-value", effect="NoExecute" + ) + ] + + with self.mock_put("lke/clusters/18881/pools/456") as m: + pool.save() + + assert m.call_data == { + "tags": ["foobar"], + "count": 5, + "autoscaler": { + "enabled": True, + "min": 2, + "max": 10, + }, + "labels": { + "updated-key": "updated-value", + }, + "taints": [ + { + "key": "updated-key", + "value": "updated-value", + "effect": "NoExecute", + } + ], + } + + def test_cluster_create_with_labels_and_taints(self): + """ + Tests that an LKE cluster can be created with labels and taints. + """ + + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-mia", + "test-acl-cluster", + [ + self.client.lke.node_pool( + "g6-nanode-1", + 3, + labels={ + "foo": "bar", + }, + taints=[ + LKENodePoolTaint( + key="a", value="b", effect="NoSchedule" + ), + {"key": "b", "value": "a", "effect": "NoSchedule"}, + ], + ) + ], + "1.29", + ) + + assert m.call_data["node_pools"][0] == { + "type": "g6-nanode-1", + "count": 3, + "labels": {"foo": "bar"}, + "taints": [ + {"key": "a", "value": "b", "effect": "NoSchedule"}, + {"key": "b", "value": "a", "effect": "NoSchedule"}, + ], + } + + def test_populate_with_taints(self): + """ + Tests that LKENodePool correctly handles a list of LKENodePoolTaint and Dict objects. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + self.pool._populate( + { + "taints": [ + LKENodePoolTaint( + key="wow", value="cool", effect="NoExecute" + ), + { + "key": "foo", + "value": "bar", + "effect": "NoSchedule", + }, + ], + } + ) + + assert len(self.pool.taints) == 2 + + assert self.pool.taints[0].dict == { + "key": "wow", + "value": "cool", + "effect": "NoExecute", + } + + assert self.pool.taints[1].dict == { + "key": "foo", + "value": "bar", + "effect": "NoSchedule", + } + def test_populate_with_node_objects(self): """ Tests that LKENodePool correctly handles a list of LKENodePoolNode objects. @@ -298,11 +421,13 @@ def test_populate_with_node_dicts(self): node_dict2 = {"id": "node4", "instance_id": 104, "status": "failed"} self.pool._populate({"nodes": [node_dict1, node_dict2]}) - self.assertEqual(len(self.pool.nodes), 2) - self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) - self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) - self.assertEqual(self.pool.nodes[0].id, "node3") - self.assertEqual(self.pool.nodes[1].id, "node4") + assert len(self.pool.nodes) == 2 + + assert isinstance(self.pool.nodes[0], LKENodePoolNode) + assert isinstance(self.pool.nodes[1], LKENodePoolNode) + + assert self.pool.nodes[0].id == "node3" + assert self.pool.nodes[1].id == "node4" def test_populate_with_node_ids(self): """ @@ -313,20 +438,30 @@ def test_populate_with_node_ids(self): node_id1 = "node5" node_id2 = "node6" + # Mock instances creation - self.client.get = MagicMock( + self.client.load = MagicMock( side_effect=[ - {"id": "node5", "instance_id": 105, "status": "active"}, - {"id": "node6", "instance_id": 106, "status": "inactive"}, + LKENodePoolNode( + self.client, + {"id": "node5", "instance_id": 105, "status": "active"}, + ), + LKENodePoolNode( + self.client, + {"id": "node6", "instance_id": 106, "status": "inactive"}, + ), ] ) + self.pool._populate({"nodes": [node_id1, node_id2]}) - self.assertEqual(len(self.pool.nodes), 2) - self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) - self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) - self.assertEqual(self.pool.nodes[0].id, "node5") - self.assertEqual(self.pool.nodes[1].id, "node6") + assert len(self.pool.nodes) == 2 + + assert isinstance(self.pool.nodes[0], LKENodePoolNode) + assert isinstance(self.pool.nodes[1], LKENodePoolNode) + + assert self.pool.nodes[0].id == "node5" + assert self.pool.nodes[1].id == "node6" def test_populate_with_mixed_types(self): """ @@ -341,17 +476,20 @@ def test_populate_with_mixed_types(self): node_dict = {"id": "node8", "instance_id": 108, "status": "inactive"} node_id = "node9" # Mock instances creation - self.client.get = MagicMock( + self.client.load = MagicMock( side_effect=[ - {"id": "node9", "instance_id": 109, "status": "pending"} + LKENodePoolNode( + self.client, + {"id": "node9", "instance_id": 109, "status": "pending"}, + ) ] ) self.pool._populate({"nodes": [node1, node_dict, node_id]}) - self.assertEqual(len(self.pool.nodes), 3) - self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) - self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) - self.assertIsInstance(self.pool.nodes[2], LKENodePoolNode) - self.assertEqual(self.pool.nodes[0].id, "node7") - self.assertEqual(self.pool.nodes[1].id, "node8") - self.assertEqual(self.pool.nodes[2].id, "node9") + assert len(self.pool.nodes) == 3 + assert isinstance(self.pool.nodes[0], LKENodePoolNode) + assert isinstance(self.pool.nodes[1], LKENodePoolNode) + assert isinstance(self.pool.nodes[2], LKENodePoolNode) + assert self.pool.nodes[0].id == "node7" + assert self.pool.nodes[1].id == "node8" + assert self.pool.nodes[2].id == "node9" From cace6350f795a7451b12551c82a3dde823fa223f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 16 Aug 2024 21:27:09 -0400 Subject: [PATCH 234/379] Increase max wait time for `test_linode_resize` (#450) --- test/integration/models/linode/test_linode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index afedce93d..dd8907c0e 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -315,11 +315,11 @@ def test_linode_boot(create_linode): def test_linode_resize(create_linode_for_long_running_tests): linode = create_linode_for_long_running_tests - wait_for_condition(10, 100, get_status, linode, "running") + wait_for_condition(10, 240, get_status, linode, "running") retry_sending_request(3, linode.resize, "g6-standard-6") - wait_for_condition(10, 100, get_status, linode, "resizing") + wait_for_condition(10, 240, get_status, linode, "resizing") assert linode.status == "resizing" From 1d155a98fcf20f69114117609f3f116affc4b3e1 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:29:46 -0400 Subject: [PATCH 235/379] new: Support pricing for LKE, Volume, NodeBalancer and Network Transfer (#454) * support pricing * Add more detailed assertions for integration tests --------- Co-authored-by: ykim-1 --- linode_api4/common.py | 24 +++++++ linode_api4/groups/lke.py | 19 ++++++ linode_api4/groups/networking.py | 19 ++++++ linode_api4/groups/nodebalancer.py | 20 +++++- linode_api4/groups/volume.py | 20 +++++- linode_api4/objects/__init__.py | 2 +- linode_api4/objects/lke.py | 19 ++++++ linode_api4/objects/networking.py | 19 ++++++ linode_api4/objects/nodebalancer.py | 19 ++++++ linode_api4/objects/volume.py | 19 ++++++ test/fixtures/lke_types.json | 38 +++++++++++ test/fixtures/network-transfer_prices.json | 38 +++++++++++ test/fixtures/nodebalancers_types.json | 28 ++++++++ test/fixtures/volumes_types.json | 28 ++++++++ test/integration/models/lke/test_lke.py | 27 +++++++- .../models/networking/test_networking.py | 13 ++++ .../models/nodebalancer/test_nodebalancer.py | 26 ++++++- test/integration/models/volume/test_volume.py | 21 +++++- test/unit/linode_client_test.py | 67 +++++++++++++++++++ 19 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/lke_types.json create mode 100644 test/fixtures/network-transfer_prices.json create mode 100644 test/fixtures/nodebalancers_types.json create mode 100644 test/fixtures/volumes_types.json diff --git a/linode_api4/common.py b/linode_api4/common.py index df3da9733..7e98b1977 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -1,4 +1,7 @@ import os +from dataclasses import dataclass + +from linode_api4.objects import JSONObject SSH_KEY_TYPES = ( "ssh-dss", @@ -57,3 +60,24 @@ def load_and_validate_keys(authorized_keys): ) ) return ret + + +@dataclass +class Price(JSONObject): + """ + Price contains the core fields of a price object returned by various pricing endpoints. + """ + + hourly: int = 0 + monthly: int = 0 + + +@dataclass +class RegionPrice(JSONObject): + """ + RegionPrice contains the core fields of a region_price object returned by various pricing endpoints. + """ + + id: int = 0 + hourly: int = 0 + monthly: int = 0 diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index f2bc5a388..b60090595 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -6,6 +6,7 @@ KubeVersion, LKECluster, LKEClusterControlPlaneOptions, + LKEType, Type, drop_null_keys, ) @@ -155,3 +156,21 @@ def node_pool(self, node_type: Union[Type, str], node_count: int, **kwargs): result.update(kwargs) return result + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`LKEType` objects that represents a valid LKE type. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of LKE types that match the query. + :rtype: PaginatedList of LKEType + """ + + return self.client._get_and_filter( + LKEType, *filters, endpoint="/lke/types" + ) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 7ba6919e4..5d49e9bb3 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -8,6 +8,7 @@ IPAddress, IPv6Pool, IPv6Range, + NetworkTransferPrice, Region, ) @@ -348,3 +349,21 @@ def ip_addresses_assign(self, assignments, region): params = {"assignments": assignments, "region": region} self.client.post("/networking/ips/assign", model=self, data=params) + + def transfer_prices(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`NetworkTransferPrice` objects that represents a valid network transfer price. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of network transfer price that match the query. + :rtype: PaginatedList of NetworkTransferPrice + """ + + return self.client._get_and_filter( + NetworkTransferPrice, *filters, endpoint="/network-transfer/prices" + ) diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index 50068f8eb..acc1f07e2 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -1,6 +1,6 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, NodeBalancer +from linode_api4.objects import Base, NodeBalancer, NodeBalancerType class NodeBalancerGroup(Group): @@ -50,3 +50,21 @@ def create(self, region, **kwargs): n = NodeBalancer(self.client, result["id"], result) return n + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`NodeBalancerType` objects that represents a valid NodeBalancer type. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of NodeBalancer types that match the query. + :rtype: PaginatedList of NodeBalancerType + """ + + return self.client._get_and_filter( + NodeBalancerType, *filters, endpoint="/nodebalancers/types" + ) diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index edbfdfbf8..dc0d7c601 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -1,6 +1,6 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Volume +from linode_api4.objects import Base, Volume, VolumeType class VolumeGroup(Group): @@ -71,3 +71,21 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): v = Volume(self.client, result["id"], result) return v + + def types(self, *filters): + """ + Returns a :any:`PaginatedList` of :any:`VolumeType` objects that represents a valid Volume type. + + API Documentation: TODO + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of Volume types that match the query. + :rtype: PaginatedList of VolumeType + """ + + return self.client._get_and_filter( + VolumeType, *filters, endpoint="/volumes/types" + ) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 3ecce4584..b13fac51a 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,7 +6,7 @@ from .region import Region from .image import Image from .linode import * -from .volume import Volume +from .volume import * from .domain import * from .account import * from .networking import * diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 7889c9c07..b6471553c 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional, Union from urllib import parse +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -15,6 +16,24 @@ ) +class LKEType(Base): + """ + An LKEType represents the structure of a valid LKE type. + Currently the LKEType can only be retrieved by listing, i.e.: + types = client.lke.types() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class KubeVersion(Base): """ A KubeVersion is a version of Kubernetes that can be deployed on LKE. diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 993961098..9e19b2d92 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Optional +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, JSONObject, Property, Region @@ -256,3 +257,21 @@ def device_create(self, id, type="linode", **kwargs): c = FirewallDevice(self._client, result["id"], self.id, result) return c + + +class NetworkTransferPrice(Base): + """ + An NetworkTransferPrice represents the structure of a valid network transfer price. + Currently the NetworkTransferPrice can only be retrieved by listing, i.e.: + types = client.networking.transfer_prices() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 2aeb6180c..36d038bcb 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,6 +1,7 @@ import os from urllib import parse +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -12,6 +13,24 @@ from linode_api4.objects.networking import Firewall, IPAddress +class NodeBalancerType(Base): + """ + An NodeBalancerType represents the structure of a valid NodeBalancer type. + Currently the NodeBalancerType can only be retrieved by listing, i.e.: + types = client.nodebalancers.types() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class NodeBalancerNode(DerivedBase): """ The information about a single Node, a backend for this NodeBalancer’s configured port. diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 6b126cc75..a79e3174c 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -1,7 +1,26 @@ +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, Instance, Property, Region +class VolumeType(Base): + """ + An VolumeType represents the structure of a valid Volume type. + Currently the VolumeType can only be retrieved by listing, i.e.: + types = client.volumes.types() + + API documentation: TODO + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class Volume(Base): """ A single Block Storage Volume. Block Storage Volumes are persistent storage devices diff --git a/test/fixtures/lke_types.json b/test/fixtures/lke_types.json new file mode 100644 index 000000000..7d27a7f86 --- /dev/null +++ b/test/fixtures/lke_types.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": "lke-sa", + "label": "LKE Standard Availability", + "price": { + "hourly": 0, + "monthly": 0 + }, + "region_prices": [], + "transfer": 0 + }, + { + "id": "lke-ha", + "label": "LKE High Availability", + "price": { + "hourly": 0.09, + "monthly": 60 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.108, + "monthly": 72 + }, + { + "id": "br-gru", + "hourly": 0.126, + "monthly": 84 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/network-transfer_prices.json b/test/fixtures/network-transfer_prices.json new file mode 100644 index 000000000..d595864ef --- /dev/null +++ b/test/fixtures/network-transfer_prices.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "id": "distributed_network_transfer", + "label": "Distributed Network Transfer", + "price": { + "hourly": 0.01, + "monthly": null + }, + "region_prices": [], + "transfer": 0 + }, + { + "id": "network_transfer", + "label": "Network Transfer", + "price": { + "hourly": 0.005, + "monthly": null + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.015, + "monthly": null + }, + { + "id": "br-gru", + "hourly": 0.007, + "monthly": null + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/nodebalancers_types.json b/test/fixtures/nodebalancers_types.json new file mode 100644 index 000000000..9e5d3fa53 --- /dev/null +++ b/test/fixtures/nodebalancers_types.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": "nodebalancer", + "label": "NodeBalancer", + "price": { + "hourly": 0.015, + "monthly": 10 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.018, + "monthly": 12 + }, + { + "id": "br-gru", + "hourly": 0.021, + "monthly": 14 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/volumes_types.json b/test/fixtures/volumes_types.json new file mode 100644 index 000000000..9b975506e --- /dev/null +++ b/test/fixtures/volumes_types.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": "volume", + "label": "Storage Volume", + "price": { + "hourly": 0.00015, + "monthly": 0.1 + }, + "region_prices": [ + { + "id": "id-cgk", + "hourly": 0.00018, + "monthly": 0.12 + }, + { + "id": "br-gru", + "hourly": 0.00021, + "monthly": 0.14 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 2f659f9a7..eb31c8eb6 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -15,8 +15,14 @@ LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, ) +from linode_api4.common import RegionPrice from linode_api4.errors import ApiError -from linode_api4.objects import LKECluster, LKENodePool, LKENodePoolTaint +from linode_api4.objects import ( + LKECluster, + LKENodePool, + LKENodePoolTaint, + LKEType, +) @pytest.fixture(scope="session") @@ -320,3 +326,22 @@ def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): assert vars(pool.labels) == updated_labels assert updated_taints[0] in pool.taints assert LKENodePoolTaint.from_json(updated_taints[1]) in pool.taints + + +def test_lke_types(test_linode_client): + types = test_linode_client.lke.types() + + if len(types) > 0: + for lke_type in types: + assert type(lke_type) is LKEType + assert lke_type.price.monthly is None or ( + isinstance(lke_type.price.monthly, (float, int)) + and lke_type.price.monthly >= 0 + ) + if len(lke_type.region_prices) > 0: + region_price = lke_type.region_prices[0] + assert type(region_price) is RegionPrice + assert lke_type.price.monthly is None or ( + isinstance(lke_type.price.monthly, (float, int)) + and lke_type.price.monthly >= 0 + ) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index d9f13063e..3eb455cb4 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -3,6 +3,7 @@ import pytest from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress +from linode_api4.objects.networking import NetworkTransferPrice, Price @pytest.mark.smoke @@ -121,3 +122,15 @@ def test_ip_info_vpc(test_linode_client, create_vpc_with_subnet_and_linode): assert ip_info.vpc_nat_1_1.address == "10.0.0.2" assert ip_info.vpc_nat_1_1.vpc_id == vpc.id assert ip_info.vpc_nat_1_1.subnet_id == subnet.id + + +def test_network_transfer_prices(test_linode_client): + transfer_prices = test_linode_client.networking.transfer_prices() + + if len(transfer_prices) > 0: + assert type(transfer_prices[0]) is NetworkTransferPrice + assert type(transfer_prices[0].price) is Price + assert ( + transfer_prices[0].price is None + or transfer_prices[0].price.hourly >= 0 + ) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index ab3095aaa..a3c00cee9 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -3,7 +3,12 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import NodeBalancerConfig, NodeBalancerNode +from linode_api4.objects import ( + NodeBalancerConfig, + NodeBalancerNode, + NodeBalancerType, + RegionPrice, +) @pytest.fixture(scope="session") @@ -121,3 +126,22 @@ def test_delete_nb_node(test_linode_client, create_nb_config): (create_nb_config.id, create_nb_config.nodebalancer_id), ) assert "Not Found" in str(e.json) + + +def test_nodebalancer_types(test_linode_client): + types = test_linode_client.nodebalancers.types() + + if len(types) > 0: + for nb_type in types: + assert type(nb_type) is NodeBalancerType + assert nb_type.price.monthly is None or ( + isinstance(nb_type.price.monthly, (float, int)) + and nb_type.price.monthly >= 0 + ) + if len(nb_type.region_prices) > 0: + region_price = nb_type.region_prices[0] + assert type(region_price) is RegionPrice + assert region_price.monthly is None or ( + isinstance(region_price.monthly, (float, int)) + and region_price.monthly >= 0 + ) diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 08e836a13..820f7027a 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -9,7 +9,7 @@ import pytest from linode_api4 import ApiError, LinodeClient -from linode_api4.objects import Volume +from linode_api4.objects import RegionPrice, Volume, VolumeType @pytest.fixture(scope="session") @@ -121,3 +121,22 @@ def test_detach_volume_to_linode( # time wait for volume to detach before deletion occurs time.sleep(30) + + +def test_volume_types(test_linode_client): + types = test_linode_client.volumes.types() + + if len(types) > 0: + for volume_type in types: + assert type(volume_type) is VolumeType + assert volume_type.price.monthly is None or ( + isinstance(volume_type.price.monthly, (float, int)) + and volume_type.price.monthly >= 0 + ) + if len(volume_type.region_prices) > 0: + region_price = volume_type.region_prices[0] + assert type(region_price) is RegionPrice + assert region_price.monthly is None or ( + isinstance(region_price.monthly, (float, int)) + and region_price.monthly >= 0 + ) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 84c003e97..357826c0a 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -720,6 +720,19 @@ def test_cluster_create_with_api_objects(self): self.assertEqual(cluster.region.id, "ap-west") self.assertEqual(cluster.k8s_version.id, "1.19") + def test_lke_types(self): + """ + Tests that a list of LKETypes can be retrieved + """ + types = self.client.lke.types() + self.assertEqual(len(types), 2) + self.assertEqual(types[1].id, "lke-ha") + self.assertEqual(types[1].price.hourly, 0.09) + self.assertEqual(types[1].price.monthly, 60) + self.assertEqual(types[1].region_prices[0].id, "id-cgk") + self.assertEqual(types[1].region_prices[0].hourly, 0.108) + self.assertEqual(types[1].region_prices[0].monthly, 72) + def test_cluster_create_with_string_repr(self): """ Tests clusters can be created using string representations @@ -1236,3 +1249,57 @@ def test_ipv6_ranges(self): ranges = self.client.networking.ipv6_ranges() self.assertEqual(len(ranges), 1) self.assertEqual(ranges[0].range, "2600:3c01::") + + def test_network_transfer_prices(self): + """ + Tests that a list of NetworkTransferPrices can be retrieved + """ + transfer_prices = self.client.networking.transfer_prices() + self.assertEqual(len(transfer_prices), 2) + self.assertEqual(transfer_prices[1].id, "network_transfer") + self.assertEqual(transfer_prices[1].price.hourly, 0.005) + self.assertEqual(transfer_prices[1].price.monthly, None) + self.assertEqual(len(transfer_prices[1].region_prices), 2) + self.assertEqual(transfer_prices[1].region_prices[0].id, "id-cgk") + self.assertEqual(transfer_prices[1].region_prices[0].hourly, 0.015) + self.assertEqual(transfer_prices[1].region_prices[0].monthly, None) + + +class NodeBalancerGroupTest(ClientBaseCase): + """ + Tests methods of the NodeBalancerGroup + """ + + def test_nodebalancer_types(self): + """ + Tests that a list of NodebalancerTypes can be retrieved + """ + types = self.client.nodebalancers.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "nodebalancer") + self.assertEqual(types[0].price.hourly, 0.015) + self.assertEqual(types[0].price.monthly, 10) + self.assertEqual(len(types[0].region_prices), 2) + self.assertEqual(types[0].region_prices[0].id, "id-cgk") + self.assertEqual(types[0].region_prices[0].hourly, 0.018) + self.assertEqual(types[0].region_prices[0].monthly, 12) + + +class VolumeGroupTest(ClientBaseCase): + """ + Tests methods of the VolumeGroup + """ + + def test_volume_types(self): + """ + Tests that a list of VolumeTypes can be retrieved + """ + types = self.client.volumes.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "volume") + self.assertEqual(types[0].price.hourly, 0.00015) + self.assertEqual(types[0].price.monthly, 0.1) + self.assertEqual(len(types[0].region_prices), 2) + self.assertEqual(types[0].region_prices[0].id, "id-cgk") + self.assertEqual(types[0].region_prices[0].hourly, 0.00018) + self.assertEqual(types[0].region_prices[0].monthly, 0.12) From e4214f43a418d976439a1d6f35b46e85a9fe1bb9 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Thu, 12 Sep 2024 15:42:18 -0400 Subject: [PATCH 236/379] Added missing param to disk_create (#456) --- linode_api4/objects/linode.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 39564200f..775def88a 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1221,6 +1221,9 @@ def disk_create( root_pass=None, authorized_keys=None, authorized_users=None, + disk_encryption: Optional[ + Union[InstanceDiskEncryptionType, str] + ] = None, stackscript=None, **stackscript_args, ): @@ -1245,6 +1248,9 @@ def disk_create( as trusted for the root user. These user's keys should already be set up, see :any:`ProfileGroup.ssh_keys` for details. + :param disk_encryption: The disk encryption policy for this Linode. + NOTE: Disk encryption may not currently be available to all users. + :type disk_encryption: InstanceDiskEncryptionType or str :param stackscript: A StackScript object, or the ID of one, to deploy to this disk. Requires deploying a compatible image. :param **stackscript_args: Any arguments to pass to the StackScript, as defined @@ -1274,6 +1280,9 @@ def disk_create( "authorized_users": authorized_users, } + if disk_encryption is not None: + params["disk_encryption"] = str(disk_encryption) + if image: params.update( { From ef89bac578c84dbaeebe3bc00121368bb4cb23fb Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 19 Sep 2024 11:11:04 -0400 Subject: [PATCH 237/379] Add VPC Grant and Refactor `UserGrants` class (#455) * Add VPC grant; refactor UserGrants and make it serializable * Fix warning by adding `r` prior to a regex * Add `.DS_Store` into .gitignore --- .gitignore | 1 + linode_api4/objects/account.py | 85 ++++++++------ .../linode_client/test_linode_client.py | 2 +- test/unit/objects/account_test.py | 106 +++++++++++++++--- 4 files changed, 145 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 6043f36e5..7beded74d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ docs/_build/* venv baked_version .vscode +.DS_Store diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 9365a9127..4777ff1c4 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -5,21 +5,20 @@ import requests from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.objects import ( - DATE_FORMAT, - Base, - DerivedBase, - Domain, - Image, - Instance, - Property, - StackScript, - Volume, -) +from linode_api4.objects import DATE_FORMAT, Volume +from linode_api4.objects.base import Base, Property +from linode_api4.objects.database import Database +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.domain import Domain +from linode_api4.objects.image import Image +from linode_api4.objects.linode import Instance, StackScript from linode_api4.objects.longview import LongviewClient, LongviewSubscription +from linode_api4.objects.networking import Firewall from linode_api4.objects.nodebalancer import NodeBalancer from linode_api4.objects.profile import PersonalAccessToken from linode_api4.objects.support import SupportTicket +from linode_api4.objects.volume import Volume +from linode_api4.objects.vpc import VPC class Account(Base): @@ -554,10 +553,6 @@ def get_obj_grants(): """ Returns Grant keys mapped to Object types. """ - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - Database, - Firewall, - ) return ( ("linode", Instance), @@ -569,6 +564,7 @@ def get_obj_grants(): ("longview", LongviewClient), ("database", Database), ("firewall", Firewall), + ("vpc", VPC), ) @@ -641,10 +637,47 @@ def _populate(self, json): self.global_grants = type("global_grants", (object,), json["global"]) for key, cls in get_obj_grants(): - lst = [] - for gdct in json[key]: - lst.append(Grant(self._client, cls, gdct)) - setattr(self, key, lst) + if key in json: + lst = [] + for gdct in json[key]: + lst.append(Grant(self._client, cls, gdct)) + setattr(self, key, lst) + + @property + def _global_grants_dict(self): + """ + The global grants stored in this object. + """ + return { + k: v + for k, v in vars(self.global_grants).items() + if not k.startswith("_") + } + + @property + def _grants_dict(self): + """ + The grants stored in this object. + """ + grants = {} + for key, _ in get_obj_grants(): + if hasattr(self, key): + lst = [] + for cg in getattr(self, key): + lst.append(cg._serialize()) + grants[key] = lst + + return grants + + def _serialize(self): + """ + Returns the user grants in as JSON the api will accept. + This is only relevant in the context of UserGrants.save + """ + return { + "global": self._global_grants_dict, + **self._grants_dict, + } def save(self): """ @@ -653,19 +686,7 @@ def save(self): API Documentation: https://techdocs.akamai.com/linode-api/reference/put-user-grants """ - req = { - "global": { - k: v - for k, v in vars(self.global_grants).items() - if not k.startswith("_") - }, - } - - for key, _ in get_obj_grants(): - lst = [] - for cg in getattr(self, key): - lst.append(cg._serialize()) - req[key] = lst + req = self._serialize() result = self._client.put( UserGrants.api_endpoint.format(username=self.username), data=req diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 92224abd4..4f8f97f4a 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -222,7 +222,7 @@ def test_get_account_settings(test_linode_client): assert account_settings._populated == True assert re.search( - "'network_helper':\s*(True|False)", str(account_settings._raw_json) + r"'network_helper':\s*(True|False)", str(account_settings._raw_json) ) diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 053cc3d0e..1f9da98fb 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable +from copy import deepcopy from datetime import datetime from test.unit.base import ClientBaseCase @@ -21,10 +23,12 @@ ServiceTransfer, StackScript, User, + UserGrants, Volume, get_obj_grants, ) from linode_api4.objects.account import ChildAccount +from linode_api4.objects.vpc import VPC class InvoiceTest(ClientBaseCase): @@ -204,22 +208,6 @@ def test_get_payment_method(self): self.assertTrue(paymentMethod.is_default) self.assertEqual(paymentMethod.type, "credit_card") - def test_get_user_grant(self): - """ - Tests that a user grant is loaded correctly - """ - grants = get_obj_grants() - - self.assertTrue(grants.count(("linode", Instance)) > 0) - self.assertTrue(grants.count(("domain", Domain)) > 0) - self.assertTrue(grants.count(("stackscript", StackScript)) > 0) - self.assertTrue(grants.count(("nodebalancer", NodeBalancer)) > 0) - self.assertTrue(grants.count(("volume", Volume)) > 0) - self.assertTrue(grants.count(("image", Image)) > 0) - self.assertTrue(grants.count(("longview", LongviewClient)) > 0) - self.assertTrue(grants.count(("database", Database)) > 0) - self.assertTrue(grants.count(("firewall", Firewall)) > 0) - def test_payment_method_make_default(self): """ Tests that making a payment method default creates the correct api request. @@ -309,3 +297,89 @@ def test_child_account_create_token(self): token = child_account.create_token() self.assertEqual(token.token, "abcdefghijklmnop") self.assertEqual(m.call_data, {}) + + +def test_get_user_grant(): + """ + Tests that a user grant is loaded correctly + """ + grants = get_obj_grants() + + assert grants.count(("linode", Instance)) > 0 + assert grants.count(("domain", Domain)) > 0 + assert grants.count(("stackscript", StackScript)) > 0 + assert grants.count(("nodebalancer", NodeBalancer)) > 0 + assert grants.count(("volume", Volume)) > 0 + assert grants.count(("image", Image)) > 0 + assert grants.count(("longview", LongviewClient)) > 0 + assert grants.count(("database", Database)) > 0 + assert grants.count(("firewall", Firewall)) > 0 + assert grants.count(("vpc", VPC)) > 0 + + +def test_user_grants_serialization(): + """ + Tests that user grants from JSON is serialized correctly + """ + user_grants_json = { + "database": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "domain": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "firewall": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "global": { + "account_access": "read_only", + "add_databases": True, + "add_domains": True, + "add_firewalls": True, + "add_images": True, + "add_linodes": True, + "add_longview": True, + "add_nodebalancers": True, + "add_placement_groups": True, + "add_stackscripts": True, + "add_volumes": True, + "add_vpcs": True, + "cancel_account": False, + "child_account_access": True, + "longview_subscription": True, + }, + "image": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "linode": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "longview": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "nodebalancer": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "stackscript": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "volume": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + "vpc": [ + {"id": 123, "label": "example-entity", "permissions": "read_only"} + ], + } + + expected_serialized_grants = deepcopy(user_grants_json) + + for grants in expected_serialized_grants.values(): + if isinstance(grants, Iterable): + for grant in grants: + if isinstance(grant, dict) and "label" in grant: + del grant["label"] + + assert ( + UserGrants(None, None, user_grants_json)._serialize() + == expected_serialized_grants + ) From 35416b569ecfb396f488538f11efe6a02d327324 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:18:02 -0400 Subject: [PATCH 238/379] Refactor regions in image replicate tests; Add LA notice (#461) * refactor tests * add la notice * disable too-many-positional-arguments --- .pylintrc | 2 +- linode_api4/objects/image.py | 2 ++ test/integration/conftest.py | 10 ++++++-- test/integration/models/image/test_image.py | 28 ++++++++++++++------- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/.pylintrc b/.pylintrc index 2084a0c5d..49a156351 100644 --- a/.pylintrc +++ b/.pylintrc @@ -50,7 +50,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=consider-using-dict-items,blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call,no-value-for-parameter,c-extension-no-member,attribute-defined-outside-init,use-a-generator +disable=consider-using-dict-items,blacklisted-name,invalid-name,missing-docstring,empty-docstring,unneeded-not,singleton-comparison,misplaced-comparison-constant,unidiomatic-typecheck,consider-using-enumerate,consider-iterating-dictionary,bad-classmethod-argument,bad-mcs-method-argument,bad-mcs-classmethod-argument,single-string-used-for-slots,line-too-long,too-many-lines,trailing-whitespace,missing-final-newline,trailing-newlines,multiple-statements,superfluous-parens,bad-whitespace,mixed-line-endings,unexpected-line-ending-format,bad-continuation,wrong-spelling-in-comment,wrong-spelling-in-docstring,invalid-characters-in-docstring,multiple-imports,wrong-import-order,ungrouped-imports,wrong-import-position,old-style-class,len-as-condition,fatal,astroid-error,parse-error,method-check-failed,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,literal-comparison,no-self-use,no-classmethod-decorator,no-staticmethod-decorator,cyclic-import,duplicate-code,too-many-ancestors,too-many-instance-attributes,too-few-public-methods,too-many-public-methods,too-many-return-statements,too-many-branches,too-many-arguments,too-many-locals,too-many-statements,too-many-boolean-expressions,consider-merging-isinstance,too-many-nested-blocks,simplifiable-if-statement,redefined-argument-from-local,no-else-return,consider-using-ternary,trailing-comma-tuple,unreachable,dangerous-default-value,pointless-statement,pointless-string-statement,expression-not-assigned,unnecessary-pass,unnecessary-lambda,duplicate-key,deprecated-lambda,assign-to-new-keyword,useless-else-on-loop,exec-used,eval-used,confusing-with-statement,using-constant-test,lost-exception,assert-on-tuple,attribute-defined-outside-init,bad-staticmethod-argument,protected-access,arguments-differ,signature-differs,abstract-method,super-init-not-called,no-init,non-parent-init-called,useless-super-delegation,unnecessary-semicolon,bad-indentation,mixed-indentation,lowercase-l-suffix,wildcard-import,deprecated-module,relative-import,reimported,import-self,misplaced-future,fixme,invalid-encoded-data,global-variable-undefined,global-variable-not-assigned,global-statement,global-at-module-level,unused-import,unused-variable,unused-argument,unused-wildcard-import,redefined-outer-name,redefined-builtin,redefine-in-handler,undefined-loop-variable,cell-var-from-loop,bare-except,broad-except,duplicate-except,nonstandard-exception,binary-op-exception,property-on-old-class,logging-not-lazy,logging-format-interpolation,bad-format-string-key,unused-format-string-key,bad-format-string,missing-format-argument-key,unused-format-string-argument,format-combined-specification,missing-format-attribute,invalid-format-index,anomalous-backslash-in-string,anomalous-unicode-escape-in-string,bad-open-mode,boolean-datetime,redundant-unittest-assert,deprecated-method,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,useless-object-inheritance,comparison-with-callable,bad-option-value,consider-using-f-string,unspecified-encoding,missing-timeout,unnecessary-dunder-call,no-value-for-parameter,c-extension-no-member,attribute-defined-outside-init,use-a-generator,too-many-positional-arguments # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index b2c413f86..f3b8b3aaa 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -62,6 +62,8 @@ class Image(Base): def replicate(self, regions: Union[List[str], List[Region]]): """ + NOTE: Image replication may not currently be available to all users. + Replicate the image to other regions. Note: Image replication may not currently be available to all users. diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 220cd4093..0c73a4857 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -34,7 +34,7 @@ def get_random_label(): return label -def get_region( +def get_regions( client: LinodeClient, capabilities: Set[str] = None, site_type: str = None ): region_override = os.environ.get(ENV_REGION_OVERRIDE) @@ -53,7 +53,13 @@ def get_region( if site_type is not None: regions = [v for v in regions if v.site_type == site_type] - return random.choice(regions) + return regions + + +def get_region( + client: LinodeClient, capabilities: Set[str] = None, site_type: str = None +): + return random.choice(get_regions(client, capabilities, site_type)) def get_api_ca_file(): diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 5c4025dfc..bfb958921 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -1,5 +1,5 @@ from io import BytesIO -from test.integration.conftest import get_region +from test.integration.conftest import get_region, get_regions from test.integration.helpers import ( delete_instance_with_test_kw, get_test_label, @@ -39,15 +39,19 @@ def test_uploaded_image(test_linode_client): label = get_test_label() + "_image" + regions = get_regions( + test_linode_client, capabilities={"Object Storage"}, site_type="core" + ) + image = test_linode_client.image_upload( label, - "us-east", + regions[1].id, BytesIO(test_image_content), description="integration test image upload", tags=["tests"], ) - yield image + yield image, regions image.delete() @@ -60,16 +64,20 @@ def test_get_image(test_linode_client, image_upload_url): def test_image_create_upload(test_linode_client, test_uploaded_image): - image = test_linode_client.load(Image, test_uploaded_image.id) + uploaded_image, _ = test_uploaded_image - assert image.label == test_uploaded_image.label + image = test_linode_client.load(Image, uploaded_image.id) + + assert image.label == uploaded_image.label assert image.description == "integration test image upload" assert image.tags[0] == "tests" @pytest.mark.smoke def test_image_replication(test_linode_client, test_uploaded_image): - image = test_linode_client.load(Image, test_uploaded_image.id) + uploaded_image, regions = test_uploaded_image + + image = test_linode_client.load(Image, uploaded_image.id) # wait for image to be available for replication def poll_func() -> bool: @@ -85,8 +93,10 @@ def poll_func() -> bool: except polling.TimeoutException: print("failed to wait for image status: timeout period expired.") - # image replication works stably in these two regions - image.replicate(["us-east", "eu-west"]) + replicate_regions = [r.id for r in regions[:2]] + image.replicate(replicate_regions) - assert image.label == test_uploaded_image.label + assert image.label == uploaded_image.label assert len(image.regions) == 2 + assert image.regions[0].region in replicate_regions + assert image.regions[1].region in replicate_regions From b91f188e321abb7ac7183a1245c93d199da3cbcc Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:41:42 -0400 Subject: [PATCH 239/379] fix: Ensure all arguments with None defaults are marked as optional (#459) * Ensure all arguments with None defaults are marked as optional * Fix import issue --- linode_api4/groups/image.py | 8 ++++---- linode_api4/groups/polling.py | 4 +++- linode_api4/linode_client.py | 10 +++++----- linode_api4/objects/image.py | 4 ++-- linode_api4/objects/lke.py | 8 ++++---- linode_api4/objects/region.py | 6 +++--- linode_api4/objects/vpc.py | 2 +- linode_api4/polling.py | 2 +- test/integration/conftest.py | 6 ++++-- test/unit/objects/image_test.py | 4 ++-- 10 files changed, 29 insertions(+), 25 deletions(-) diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index 451a73d19..e644dc169 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -32,8 +32,8 @@ def __call__(self, *filters): def create( self, disk: Union[Disk, int], - label: str = None, - description: str = None, + label: Optional[str] = None, + description: Optional[str] = None, cloud_init: bool = False, tags: Optional[List[str]] = None, ): @@ -82,7 +82,7 @@ def create_upload( self, label: str, region: str, - description: str = None, + description: Optional[str] = None, cloud_init: bool = False, tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: @@ -132,7 +132,7 @@ def upload( label: str, region: str, file: BinaryIO, - description: str = None, + description: Optional[str] = None, tags: Optional[List[str]] = None, ) -> Image: """ diff --git a/linode_api4/groups/polling.py b/linode_api4/groups/polling.py index 7dff2d3d5..8ef2c4feb 100644 --- a/linode_api4/groups/polling.py +++ b/linode_api4/groups/polling.py @@ -1,3 +1,5 @@ +from typing import Optional + import polling from linode_api4.groups import Group @@ -13,7 +15,7 @@ def event_poller_create( self, entity_type: str, action: str, - entity_id: int = None, + entity_id: Optional[int] = None, ) -> EventPoller: """ Creates a new instance of the EventPoller class. diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 66e3d45fe..1bbc631b7 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -3,7 +3,7 @@ import json import logging from importlib.metadata import version -from typing import BinaryIO, List, Tuple +from typing import BinaryIO, List, Optional, Tuple from urllib import parse import requests @@ -391,8 +391,8 @@ def image_create_upload( self, label: str, region: str, - description: str = None, - tags: List[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -409,8 +409,8 @@ def image_upload( label: str, region: str, file: BinaryIO, - description: str = None, - tags: List[str] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index f3b8b3aaa..c9ac43863 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Union +from typing import List, Optional, Union from linode_api4.objects import Base, Property, Region from linode_api4.objects.serializable import JSONObject, StrEnum @@ -25,7 +25,7 @@ class ImageRegion(JSONObject): """ region: str = "" - status: ReplicationStatus = None + status: Optional[ReplicationStatus] = None class Image(Base): diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index b6471553c..b0e628196 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -103,8 +103,8 @@ class LKEClusterControlPlaneACLAddresses(JSONObject): to access an LKE cluster's control plane. """ - ipv4: List[str] = None - ipv6: List[str] = None + ipv4: Optional[List[str]] = None + ipv6: Optional[List[str]] = None @dataclass @@ -117,7 +117,7 @@ class LKEClusterControlPlaneACL(JSONObject): """ enabled: bool = False - addresses: LKEClusterControlPlaneACLAddresses = None + addresses: Optional[LKEClusterControlPlaneACLAddresses] = None class LKENodePoolNode: @@ -351,7 +351,7 @@ def node_pool_create( self, node_type: Union[Type, str], node_count: int, - labels: Dict[str, str] = None, + labels: Optional[Dict[str, str]] = None, taints: List[Union[LKENodePoolTaint, Dict[str, Any]]] = None, **kwargs, ): diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 6d8178eff..34577c336 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from linode_api4.errors import UnexpectedResponseError from linode_api4.objects.base import Base, JSONObject, Property @@ -59,6 +59,6 @@ class RegionAvailabilityEntry(JSONObject): API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region-availability """ - region: str = None - plan: str = None + region: Optional[str] = None + plan: Optional[str] = None available: bool = False diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 456bdcfbc..e4fe36c78 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -16,7 +16,7 @@ class VPCSubnetLinodeInterface(JSONObject): @dataclass class VPCSubnetLinode(JSONObject): id: int = 0 - interfaces: List[VPCSubnetLinodeInterface] = None + interfaces: Optional[List[VPCSubnetLinodeInterface]] = None class VPCSubnet(DerivedBase): diff --git a/linode_api4/polling.py b/linode_api4/polling.py index 947e59e47..7dc08d915 100644 --- a/linode_api4/polling.py +++ b/linode_api4/polling.py @@ -104,7 +104,7 @@ def __init__( client: "LinodeClient", entity_type: str, action: str, - entity_id: int = None, + entity_id: Optional[int] = None, ): self._client = client self._entity_type = entity_type diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 0c73a4857..9db01cb89 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -2,7 +2,7 @@ import os import random import time -from typing import Set +from typing import Optional, Set import pytest import requests @@ -35,7 +35,9 @@ def get_random_label(): def get_regions( - client: LinodeClient, capabilities: Set[str] = None, site_type: str = None + client: LinodeClient, + capabilities: Optional[Set[str]] = None, + site_type: Optional[str] = None, ): region_override = os.environ.get(ENV_REGION_OVERRIDE) diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index d4851e777..5d1ce42d5 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -1,7 +1,7 @@ from datetime import datetime from io import BytesIO from test.unit.base import ClientBaseCase -from typing import BinaryIO +from typing import BinaryIO, Optional from unittest.mock import patch from linode_api4.objects import Image, Region @@ -95,7 +95,7 @@ def test_image_upload(self): Test that an image can be uploaded. """ - def put_mock(url: str, data: BinaryIO = None, **kwargs): + def put_mock(url: str, data: Optional[BinaryIO] = None, **kwargs): self.assertEqual(url, "https://linode.com/") self.assertEqual(data.read(), TEST_IMAGE_CONTENT) From dddecc815f0491a25c207b854f66cabc5455e1e4 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 25 Sep 2024 10:11:47 -0700 Subject: [PATCH 240/379] Adding retries for flaky tests (#458) --- test/integration/linode_client/test_linode_client.py | 1 + test/integration/models/image/test_image.py | 1 + test/integration/models/linode/test_linode.py | 3 +++ test/integration/models/lke/test_lke.py | 1 + test/integration/models/profile/test_profile.py | 4 ++++ tox.ini | 1 + 6 files changed, 11 insertions(+) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 4f8f97f4a..105535211 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -87,6 +87,7 @@ def test_get_regions(test_linode_client): @pytest.mark.smoke +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_image_create(setup_client_and_linode): client = setup_client_and_linode[0] linode = setup_client_and_linode[1] diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index bfb958921..4c2aa77d2 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -74,6 +74,7 @@ def test_image_create_upload(test_linode_client, test_uploaded_image): @pytest.mark.smoke +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_image_replication(test_linode_client, test_uploaded_image): uploaded_image, regions = test_uploaded_image diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index dd8907c0e..d6b272d9b 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -312,6 +312,7 @@ def test_linode_boot(create_linode): assert linode.status == "running" +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_resize(create_linode_for_long_running_tests): linode = create_linode_for_long_running_tests @@ -590,6 +591,7 @@ def test_get_linode_types_overrides(test_linode_client): assert linode_type.region_prices[0].monthly >= 0 +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_save_linode_noforce(test_linode_client, create_linode): linode = create_linode old_label = linode.label @@ -601,6 +603,7 @@ def test_save_linode_noforce(test_linode_client, create_linode): assert old_label != linode.label +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_save_linode_force(test_linode_client, create_linode): linode = create_linode old_label = linode.label diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index eb31c8eb6..f4f92f921 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -277,6 +277,7 @@ def test_lke_cluster_acl(lke_cluster_with_acl): assert not cluster.control_plane_acl.enabled +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): pool = lke_cluster_with_labels_and_taints.pools[0] diff --git a/test/integration/models/profile/test_profile.py b/test/integration/models/profile/test_profile.py index cafec12ea..b57c8de17 100644 --- a/test/integration/models/profile/test_profile.py +++ b/test/integration/models/profile/test_profile.py @@ -1,3 +1,5 @@ +import pytest + from linode_api4.objects import PersonalAccessToken, Profile, SSHKey @@ -18,6 +20,7 @@ def test_get_personal_access_token_objects(test_linode_client): assert isinstance(personal_access_tokens[0], PersonalAccessToken) +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_get_sshkeys(test_linode_client, test_sshkey): client = test_linode_client @@ -29,6 +32,7 @@ def test_get_sshkeys(test_linode_client, test_sshkey): assert test_sshkey.label in ssh_labels +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_ssh_key_create(test_sshkey, ssh_key_gen): pub_key = ssh_key_gen[0] key = test_sshkey diff --git a/tox.ini b/tox.ini index 209db7170..266c26717 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ deps = mock pylint httpretty + pytest-rerunfailures commands = python -m pip install . coverage run --source linode_api4 -m pytest test/unit From 77dde133f2ba7b77e2edda217a2f3c5c66455c55 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:52:28 -0700 Subject: [PATCH 241/379] test: Add cloud firewalls to migration tests and improve wait times to disk related tests (#460) * Add proper wait times in fixture for disk related tests; Add cloud firewall to migration test * unskipping migration test after LDE enabled on specified region --- test/integration/models/linode/test_linode.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index d6b272d9b..f0745901e 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -107,12 +107,12 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): # Provisioning time wait_for_condition(10, 300, get_status, linode_instance, "running") - linode_instance.shutdown() + send_request_when_resource_available(300, linode_instance.shutdown) wait_for_condition(10, 100, get_status, linode_instance, "offline") # Now it allocates 100% disk space hence need to clear some space for tests - linode_instance.disks[1].delete() + send_request_when_resource_available(300, linode_instance.disks[1].delete) test_linode_client.polling.event_poller_create( "linode", "disk_delete", entity_id=linode_instance.id @@ -513,21 +513,25 @@ def test_linode_ips(create_linode): assert ips.ipv4.public[0].address == linode.ipv4[0] -def test_linode_initate_migration(test_linode_client): +def test_linode_initate_migration(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] label = get_test_label() + "_migration" linode, _ = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian12", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, ) # Says it could take up to ~6 hrs for migration to fully complete send_request_when_resource_available( 300, linode.initiate_migration, - region="us-mia", + region="us-central", migration_type=MigrationType.COLD, ) From e2b8c46138ba6e1f380c1e91f17935412f8dcd0b Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Thu, 26 Sep 2024 11:10:08 -0700 Subject: [PATCH 242/379] test: Update smoke test coverage and improve nightly test workflow (#452) * update smoke test coverage and workflow * make format lint * Replace webhook with slack oauth token * Add fancy slack payload * Clean up slack payload syntax * Clean up slack payload syntax * fix invalid slack payload syntax * fix invalid slack payload syntax * setting payload variable in separate step * fix slack payload syntax * fix slack payload syntax * fix slack payload syntax * fix slack payload syntax * add repository name in slack payload * add slack notifications and conditions --- .github/workflows/e2e-test.yml | 66 +++++++++++++++++ .github/workflows/nightly-smoke-tests.yml | 71 +++++++++++++++++++ .../login_client/test_login_client.py | 1 + test/integration/models/lke/test_lke.py | 1 + .../models/nodebalancer/test_nodebalancer.py | 1 + .../models/object_storage/test_obj.py | 1 + .../models/placement/test_placement.py | 4 ++ .../models/profile/test_profile.py | 2 + test/integration/models/vpc/test_vpc.py | 3 + 9 files changed, 150 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 48cb55e13..5b6b08111 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -91,3 +91,69 @@ jobs: env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + + notify-slack: + runs-on: ubuntu-latest + needs: [integration-tests] + if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + + steps: + - name: Notify Slack + uses: slackapi/slack-github-action@v1.27.0 + with: + channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Build Result:*\n${{ steps.integration-tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n`${{ github.ref_name }}`" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + }, + { + "type": "mrkdwn", + "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + } + ] + }, + { + "type": "divider" + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + } + ] + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index b1a0fcbfd..c0b7b87c1 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -4,9 +4,17 @@ on: schedule: - cron: "0 0 * * *" workflow_dispatch: + inputs: + sha: + description: 'Commit SHA to test' + required: false + default: '' + type: string + jobs: smoke_tests: + if: github.repository == 'linode/linode_api4-python' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: @@ -29,7 +37,70 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run smoke tests + id: smoke_tests run: | make smoketest env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + - name: Notify Slack + if: always() && github.repository == 'linode/linode_api4-python' + uses: slackapi/slack-github-action@v1.27.0 + with: + channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n`${{ github.ref_name }}`" + } + ] + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + }, + { + "type": "mrkdwn", + "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + } + ] + }, + { + "type": "divider" + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + } + ] + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py index 7cb4246ea..ccbeb1976 100644 --- a/test/integration/login_client/test_login_client.py +++ b/test/integration/login_client/test_login_client.py @@ -27,6 +27,7 @@ def test_oauth_client_two(test_linode_client): oauth_client.delete() +@pytest.mark.smoke def test_get_oathclient(test_linode_client, test_oauth_client): client = test_linode_client diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index f4f92f921..bd0692dcc 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -127,6 +127,7 @@ def test_get_lke_clusters(test_linode_client, lke_cluster): assert cluster._raw_json == lke_cluster._raw_json +@pytest.mark.smoke def test_get_lke_pool(test_linode_client, lke_cluster): cluster = lke_cluster diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index a3c00cee9..5581c9029 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -78,6 +78,7 @@ def test_create_nb_node( assert "node_test" == node.label +@pytest.mark.smoke def test_get_nb_node(test_linode_client, create_nb_config): node = test_linode_client.load( NodeBalancerNode, diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 3042f326a..82b2da022 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -93,6 +93,7 @@ def test_bucket( assert any(b.label == bucket.label for b in buckets) +@pytest.mark.smoke def test_list_obj_storage_bucket( test_linode_client: LinodeClient, bucket: ObjectStorageBucket, diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py index 7919ef432..db570aa9e 100644 --- a/test/integration/models/placement/test_placement.py +++ b/test/integration/models/placement/test_placement.py @@ -1,6 +1,9 @@ +import pytest + from linode_api4 import PlacementGroup +@pytest.mark.smoke def test_get_pg(test_linode_client, create_placement_group): """ Tests that a Placement Group can be loaded. @@ -9,6 +12,7 @@ def test_get_pg(test_linode_client, create_placement_group): assert pg.id == create_placement_group.id +@pytest.mark.smoke def test_update_pg(test_linode_client, create_placement_group): """ Tests that a Placement Group can be updated successfully. diff --git a/test/integration/models/profile/test_profile.py b/test/integration/models/profile/test_profile.py index b57c8de17..6942eea38 100644 --- a/test/integration/models/profile/test_profile.py +++ b/test/integration/models/profile/test_profile.py @@ -3,6 +3,7 @@ from linode_api4.objects import PersonalAccessToken, Profile, SSHKey +@pytest.mark.smoke def test_user_profile(test_linode_client): client = test_linode_client @@ -20,6 +21,7 @@ def test_get_personal_access_token_objects(test_linode_client): assert isinstance(personal_access_tokens[0], PersonalAccessToken) +@pytest.mark.smoke @pytest.mark.flaky(reruns=3, reruns_delay=2) def test_get_sshkeys(test_linode_client, test_sshkey): client = test_linode_client diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 6af3380b7..5dd14b502 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -5,12 +5,14 @@ from linode_api4 import VPC, ApiError, VPCSubnet +@pytest.mark.smoke def test_get_vpc(test_linode_client, create_vpc): vpc = test_linode_client.load(VPC, create_vpc.id) test_linode_client.vpcs() assert vpc.id == create_vpc.id +@pytest.mark.smoke def test_update_vpc(test_linode_client, create_vpc): vpc = create_vpc new_label = create_vpc.label + "-updated" @@ -33,6 +35,7 @@ def test_get_subnet(test_linode_client, create_vpc_with_subnet): assert loaded_subnet.id == subnet.id +@pytest.mark.smoke def test_update_subnet(test_linode_client, create_vpc_with_subnet): vpc, subnet = create_vpc_with_subnet new_label = subnet.label + "-updated" From 46d6d18a3a580c40b3a046339413027d74d65de0 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 8 Oct 2024 17:23:14 -0400 Subject: [PATCH 243/379] Resolve circular imports and restore top-level imports (#462) --- linode_api4/objects/linode.py | 29 ++++++++++++----------------- linode_api4/objects/networking.py | 5 ++++- linode_api4/objects/nodebalancer.py | 10 +++------- linode_api4/objects/volume.py | 4 +++- linode_api4/objects/vpc.py | 6 +----- 5 files changed, 23 insertions(+), 31 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 775def88a..12c75d85c 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -11,18 +11,19 @@ from linode_api4 import util from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import ( - Base, - DerivedBase, - Image, - JSONObject, - Property, - Region, -) -from linode_api4.objects.base import MappedObject +from linode_api4.objects.base import Base, MappedObject, Property +from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.filtering import FilterableAttribute -from linode_api4.objects.networking import IPAddress, IPv6Range, VPCIPAddress -from linode_api4.objects.serializable import StrEnum +from linode_api4.objects.image import Image +from linode_api4.objects.networking import ( + Firewall, + IPAddress, + IPv6Range, + VPCIPAddress, +) +from linode_api4.objects.nodebalancer import NodeBalancer +from linode_api4.objects.region import Region +from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList @@ -1618,9 +1619,6 @@ def firewalls(self): :returns: A List of Firewalls of the Linode Instance. :rtype: List[Firewall] """ - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - Firewall, - ) result = self._client.get( "{}/firewalls".format(Instance.api_endpoint), model=self @@ -1640,9 +1638,6 @@ def nodebalancers(self): :returns: A List of Nodebalancers of the Linode Instance. :rtype: List[Nodebalancer] """ - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - NodeBalancer, - ) result = self._client.get( "{}/nodebalancers".format(Instance.api_endpoint), model=self diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 9e19b2d92..c4fff1ac3 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -3,7 +3,10 @@ from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, DerivedBase, JSONObject, Property, Region +from linode_api4.objects.base import Base, Property +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.region import Region +from linode_api4.objects.serializable import JSONObject class IPv6Pool(Base): diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 36d038bcb..d038b6998 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -3,14 +3,10 @@ from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import ( - Base, - DerivedBase, - MappedObject, - Property, - Region, -) +from linode_api4.objects.base import Base, MappedObject, Property +from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.networking import Firewall, IPAddress +from linode_api4.objects.region import Region class NodeBalancerType(Base): diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index a79e3174c..6c8514f9f 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -1,6 +1,8 @@ from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects import Base, Instance, Property, Region +from linode_api4.objects.base import Base, Property +from linode_api4.objects.linode import Instance, Region +from linode_api4.objects.region import Region class VolumeType(Base): diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index e4fe36c78..3c9a4aaba 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -3,6 +3,7 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects.networking import VPCIPAddress from linode_api4.objects.serializable import JSONObject from linode_api4.paginated_list import PaginatedList @@ -110,11 +111,6 @@ def ips(self) -> PaginatedList: :rtype: PaginatedList of VPCIPAddress """ - # need to avoid circular import - from linode_api4.objects import ( # pylint: disable=import-outside-toplevel - VPCIPAddress, - ) - return self._client._get_and_filter( VPCIPAddress, endpoint="/vpcs/{}/ips".format(self.id) ) From 9d63e07b11c881972d08a9673707a843dbd1398a Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Tue, 8 Oct 2024 14:46:16 -0700 Subject: [PATCH 244/379] test: Address pytest warnings; remove duplicate test helper (#463) * address pytest warnings, duplicate test helper * update description * update description * remove pytest.ini file; add pytest markers to toml * fix event test * add e2e firewall to test instance * add e2e firewall to test instance --- pyproject.toml | 7 +++++++ test/integration/helpers.py | 6 ------ test/integration/models/account/test_account.py | 16 ++++++++++++---- .../integration/models/firewall/test_firewall.py | 5 +++-- .../models/networking/test_networking.py | 4 ++-- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6720a965c..b8b57880b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ dev = [ "sphinxcontrib-fulltoc>=1.2.0", "build>=0.10.0", "twine>=4.0.2", + "pytest-rerunfailures", ] doc = [ @@ -88,3 +89,9 @@ in-place = true recursive = true remove-all-unused-imports = true remove-duplicate-keys = false + +[tool.pytest.ini_options] +markers = [ + "smoke: mark a test as a smoke test", + "flaky: mark a test as a flaky test for rerun" +] \ No newline at end of file diff --git a/test/integration/helpers.py b/test/integration/helpers.py index e0aab06c4..e874ea7e2 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -12,12 +12,6 @@ def get_test_label(): return label -def get_rand_nanosec_test_label(): - unique_timestamp = str(time.time_ns())[:-3] - label = "test_" + unique_timestamp - return label - - def delete_instance_with_test_kw(paginated_list: PaginatedList): for i in paginated_list: try: diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index a9dce4a3a..ab20ee079 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -61,7 +61,7 @@ def test_get_account_settings(test_linode_client): @pytest.mark.smoke -def test_latest_get_event(test_linode_client): +def test_latest_get_event(test_linode_client, e2e_test_firewall): client = test_linode_client available_regions = client.regions() @@ -69,16 +69,24 @@ def test_latest_get_event(test_linode_client): label = get_test_label() linode, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", + chosen_region, + image="linode/debian10", + label=label, + firewall=e2e_test_firewall, ) events = client.load(Event, "") - latest_event = events._raw_json.get("data")[0] + latest_events = events._raw_json.get("data") linode.delete() - assert label in latest_event["entity"]["label"] + for event in latest_events[:15]: + if label == event["entity"]["label"]: + break + else: + assert False, f"Linode '{label}' not found in the last 15 events" def test_get_user(test_linode_client): diff --git a/test/integration/models/firewall/test_firewall.py b/test/integration/models/firewall/test_firewall.py index 7a7f58ff1..7f907cc2f 100644 --- a/test/integration/models/firewall/test_firewall.py +++ b/test/integration/models/firewall/test_firewall.py @@ -1,4 +1,5 @@ import time +from test.integration.helpers import get_test_label import pytest @@ -10,7 +11,7 @@ def linode_fw(test_linode_client): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] - label = "linode_instance_fw_device" + label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", chosen_region, image="linode/debian10", label=label @@ -79,6 +80,6 @@ def test_get_device(test_linode_client, test_firewall, linode_fw): FirewallDevice, firewall.devices.first().id, firewall.id ) - assert firewall_device.entity.label == "linode_instance_fw_device" + assert "test_" in firewall_device.entity.label assert firewall_device.entity.type == "linode" assert "/v4/linode/instances/" in firewall_device.entity.url diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 3eb455cb4..a52f38ef2 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,4 +1,4 @@ -from test.integration.helpers import get_rand_nanosec_test_label +from test.integration.helpers import get_test_label import pytest @@ -22,7 +22,7 @@ def create_linode(test_linode_client): client = test_linode_client available_regions = client.regions() chosen_region = available_regions[4] - label = get_rand_nanosec_test_label() + label = get_test_label() linode_instance, _ = client.linode.instance_create( "g6-nanode-1", From 6fcc0695254c432c33f39e36ff145c958027525d Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Date: Wed, 9 Oct 2024 09:03:18 -0700 Subject: [PATCH 245/379] fix slack payload (#464) --- .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 5b6b08111..848154b55 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -120,7 +120,7 @@ jobs: "fields": [ { "type": "mrkdwn", - "text": "*Build Result:*\n${{ steps.integration-tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + "text": "*Build Result:*\n${{ needs.integration-tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" }, { "type": "mrkdwn", From f7c6eef1c37949f7d4ddf7109df98db8ee98a4ce Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Mon, 14 Oct 2024 11:28:06 -0400 Subject: [PATCH 246/379] Added support for Block Storage Encryption (#453) * Implemented changes for Linode Disk Encryption * Added more test cases * Added LA note --- linode_api4/groups/volume.py | 4 ++- linode_api4/objects/linode.py | 1 + linode_api4/objects/volume.py | 1 + test/fixtures/volumes.json | 17 ++++++++++- test/integration/conftest.py | 29 +++++++++++++++++++ test/integration/models/linode/test_linode.py | 26 +++++++++++++++++ test/integration/models/volume/test_volume.py | 9 ++++++ test/unit/linode_client_test.py | 2 +- test/unit/objects/volume_test.py | 4 +++ 9 files changed, 90 insertions(+), 3 deletions(-) diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index dc0d7c601..3a30de762 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -45,7 +45,9 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): tags included do not exist, they will be created as part of this operation. :type tags: list[str] - + :param encryption: Whether the new Volume should opt in or out of disk encryption. + :type encryption: str + Note: Block Storage Disk Encryption is not currently available to all users. :returns: The new Volume. :rtype: Volume """ diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 12c75d85c..cb5c9d9af 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -679,6 +679,7 @@ class Instance(Base): "has_user_data": Property(), "disk_encryption": Property(), "lke_cluster_id": Property(), + "capabilities": Property(unordered=True), } @property diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 6c8514f9f..58764e8d7 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -46,6 +46,7 @@ class Volume(Base): "filesystem_path": Property(), "hardware_type": Property(), "linode_label": Property(), + "encryption": Property(), } def attach(self, to_linode, config=None): diff --git a/test/fixtures/volumes.json b/test/fixtures/volumes.json index 18ba4f6da..2e8c86338 100644 --- a/test/fixtures/volumes.json +++ b/test/fixtures/volumes.json @@ -41,9 +41,24 @@ "filesystem_path": "this/is/a/file/path", "hardware_type": "nvme", "linode_label": "some_label" + }, + { + "id": 4, + "label": "block4", + "created": "2017-08-04T03:00:00", + "region": "ap-west-1a", + "linode_id": null, + "size": 40, + "updated": "2017-08-04T04:00:00", + "status": "active", + "tags": ["something"], + "filesystem_path": "this/is/a/file/path", + "hardware_type": "hdd", + "linode_label": null, + "encryption": "enabled" } ], - "results": 3, + "results": 4, "pages": 1, "page": 1 } diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 9db01cb89..cb1305d68 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -295,6 +295,35 @@ def test_volume(test_linode_client): raise e +@pytest.fixture(scope="session") +def test_volume_with_encryption(test_linode_client): + client = test_linode_client + timestamp = str(time.time_ns()) + region = get_region(client, {"Block Storage Encryption"}) + label = "TestSDK-" + timestamp + + volume = client.volume_create( + label=label, region=region, encryption="enabled" + ) + + yield volume + + timeout = 100 # give 100s for volume to be detached before deletion + + start_time = time.time() + + while time.time() - start_time < timeout: + try: + res = volume.delete() + if res: + break + else: + time.sleep(3) + except ApiError as e: + if time.time() - start_time > timeout: + raise e + + @pytest.fixture def test_tag(test_linode_client): client = test_linode_client diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index f0745901e..6d461cdf1 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -123,6 +123,25 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture +def linode_with_block_storage_encryption(test_linode_client, e2e_test_firewall): + client = test_linode_client + chosen_region = get_region(client, {"Linodes", "Block Storage Encryption"}) + label = get_test_label() + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + chosen_region, + image="linode/alpine3.19", + label=label + "block-storage-encryption", + firewall=e2e_test_firewall, + ) + + yield linode_instance + + linode_instance.delete() + + @pytest.fixture def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -440,6 +459,13 @@ def test_linode_with_disk_encryption_disabled(linode_with_disk_encryption): ) +def test_linode_with_block_storage_encryption( + linode_with_block_storage_encryption, +): + linode = linode_with_block_storage_encryption + assert "Block Storage Encryption" in linode.capabilities + + def wait_for_disk_status(disk: Disk, timeout): start_time = time.time() while True: diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 820f7027a..19bc55c26 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -60,6 +60,15 @@ def test_get_volume(test_linode_client, test_volume): assert volume.id == test_volume.id +def test_get_volume_with_encryption( + test_linode_client, test_volume_with_encryption +): + volume = test_linode_client.load(Volume, test_volume_with_encryption.id) + + assert volume.id == test_volume_with_encryption.id + assert volume.encryption == "enabled" + + def test_update_volume_tag(test_linode_client, test_volume): volume = test_volume tag_1 = "volume_test_tag1" diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 357826c0a..41cb9100d 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -150,7 +150,7 @@ def test_image_create(self): def test_get_volumes(self): v = self.client.volumes() - self.assertEqual(len(v), 3) + self.assertEqual(len(v), 4) self.assertEqual(v[0].label, "block1") self.assertEqual(v[0].region.id, "us-east-1a") self.assertEqual(v[1].label, "block2") diff --git a/test/unit/objects/volume_test.py b/test/unit/objects/volume_test.py index c18ac8d89..1344c2b94 100644 --- a/test/unit/objects/volume_test.py +++ b/test/unit/objects/volume_test.py @@ -31,6 +31,10 @@ def test_get_volume(self): self.assertEqual(volume.hardware_type, "hdd") self.assertEqual(volume.linode_label, None) + def test_get_volume_with_encryption(self): + volume = Volume(self.client, 4) + self.assertEqual(volume.encryption, "enabled") + def test_update_volume_tags(self): """ Tests that updating tags on an entity send the correct request From 0139b5135d2d64b27fa02a008533ebf12cb5756a Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 14 Oct 2024 15:15:46 -0400 Subject: [PATCH 247/379] Add include_none_values ClassVar to JSONObject; apply to response-only classes (#466) * Add include_none_values ClassVar to JSONObject; apply to response-only structures * Add unit test case --- linode_api4/objects/image.py | 2 ++ linode_api4/objects/lke.py | 6 ++++++ linode_api4/objects/serializable.py | 8 +++++++- test/unit/objects/serializable_test.py | 21 +++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index c9ac43863..931ed4a31 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -24,6 +24,8 @@ class ImageRegion(JSONObject): The region and status of an image replica. """ + include_none_values = True + region: str = "" status: Optional[ReplicationStatus] = None diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index b0e628196..1c2ed3c1a 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -55,6 +55,8 @@ class LKENodePoolTaint(JSONObject): applied to a node pool. """ + include_none_values = True + key: Optional[str] = None value: Optional[str] = None effect: Optional[str] = None @@ -103,6 +105,8 @@ class LKEClusterControlPlaneACLAddresses(JSONObject): to access an LKE cluster's control plane. """ + include_none_values = True + ipv4: Optional[List[str]] = None ipv6: Optional[List[str]] = None @@ -116,6 +120,8 @@ class LKEClusterControlPlaneACL(JSONObject): NOTE: Control Plane ACLs may not currently be available to all users. """ + include_none_values = True + enabled: bool = False addresses: Optional[LKEClusterControlPlaneACLAddresses] = None diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index b0e7a2503..fea682f43 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -58,6 +58,12 @@ class JSONObject(metaclass=JSONFilterableMetaclass): ) """ + include_none_values: ClassVar[bool] = False + """ + If true, all None values for this class will be explicitly included in + the serialized output for instance of this class. + """ + always_include: ClassVar[Set[str]] = {} """ A set of keys corresponding to fields that should always be @@ -169,7 +175,7 @@ def should_include(key: str, value: Any) -> bool: Returns whether the given key/value pair should be included in the resulting dict. """ - if key in cls.always_include: + if cls.include_none_values or key in cls.always_include: return True hint = type_hints.get(key) diff --git a/test/unit/objects/serializable_test.py b/test/unit/objects/serializable_test.py index 579417e1c..a15f108b4 100644 --- a/test/unit/objects/serializable_test.py +++ b/test/unit/objects/serializable_test.py @@ -26,3 +26,24 @@ class Foo(JSONObject): assert foo["foo"] == "test" assert foo["bar"] == "test2" assert foo["baz"] == "test3" + + def test_serialize_optional_include_None(self): + @dataclass + class Foo(JSONObject): + include_none_values = True + + foo: Optional[str] = None + bar: Optional[str] = None + baz: str = None + + foo = Foo().dict + + assert foo["foo"] is None + assert foo["bar"] is None + assert foo["baz"] is None + + foo = Foo(foo="test", bar="test2", baz="test3").dict + + assert foo["foo"] == "test" + assert foo["bar"] == "test2" + assert foo["baz"] == "test3" From 60622fc2082e9fb9175725bd8d859ab1203765de Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:35:03 -0400 Subject: [PATCH 248/379] Improve ApiError message formatting; add `response` field to ApiError and UnexpectedResponseError (#467) * Improve ApiError and UnexpectedResponseError * make format * Fix again * Remove comma from multiline string --- linode_api4/errors.py | 121 +++++++++++++++++- linode_api4/linode_client.py | 20 +-- linode_api4/login_client.py | 11 +- linode_api4/objects/account.py | 15 +-- linode_api4/objects/support.py | 9 +- test/integration/linode_client/test_errors.py | 28 ++++ test/unit/errors_test.py | 104 +++++++++++++++ 7 files changed, 269 insertions(+), 39 deletions(-) create mode 100644 test/integration/linode_client/test_errors.py create mode 100644 test/unit/errors_test.py diff --git a/linode_api4/errors.py b/linode_api4/errors.py index bc2df6108..511ac8c57 100644 --- a/linode_api4/errors.py +++ b/linode_api4/errors.py @@ -1,4 +1,11 @@ +# Necessary to maintain compatibility with Python < 3.11 +from __future__ import annotations + from builtins import super +from json import JSONDecodeError +from typing import Any, Dict, Optional + +from requests import Response class ApiError(RuntimeError): @@ -8,14 +15,90 @@ class ApiError(RuntimeError): often, this will be caused by invalid input to the API. """ - def __init__(self, message, status=400, json=None): + def __init__( + self, + message: str, + status: int = 400, + json: Optional[Dict[str, Any]] = None, + response: Optional[Response] = None, + ): super().__init__(message) + self.status = status self.json = json + self.response = response + self.errors = [] + if json and "errors" in json and isinstance(json["errors"], list): self.errors = [e["reason"] for e in json["errors"]] + @classmethod + def from_response( + cls, + response: Response, + message: Optional[str] = None, + disable_formatting: bool = False, + ) -> Optional[ApiError]: + """ + Creates an ApiError object from the given response, + or None if the response does not contain an error. + + :arg response: The response to create an ApiError from. + :arg message: An optional message to prepend to the error's message. + :arg disable_formatting: If true, the error's message will not automatically be formatted + with details from the API response. + + :returns: The new API error. + """ + + if response.status_code < 400 or response.status_code > 599: + # No error was found + return None + + request = response.request + + try: + response_json = response.json() + except JSONDecodeError: + response_json = None + + # Use the user-defined message is formatting is disabled + if disable_formatting: + return cls( + message, + status=response.status_code, + json=response_json, + response=response, + ) + + # Build the error string + error_fmt = "N/A" + + if response_json is not None and "errors" in response_json: + errors = [] + + for error in response_json["errors"]: + field = error.get("field") + reason = error.get("reason") + errors.append(f"{field + ': ' if field else ''}{reason}") + + error_fmt = "; ".join(errors) + + elif len(response.text or "") > 0: + error_fmt = response.text + + return cls( + ( + f"{message + ': ' if message is not None else ''}" + f"{f'{request.method} {request.path_url}: ' if request else ''}" + f"[{response.status_code}] {error_fmt}" + ), + status=response.status_code, + json=response_json, + response=response, + ) + class UnexpectedResponseError(RuntimeError): """ @@ -26,7 +109,41 @@ class UnexpectedResponseError(RuntimeError): library, and should be fixed with changes to this codebase. """ - def __init__(self, message, status=200, json=None): + def __init__( + self, + message: str, + status: int = 200, + json: Optional[Dict[str, Any]] = None, + response: Optional[Response] = None, + ): super().__init__(message) + self.status = status self.json = json + self.response = response + + @classmethod + def from_response( + cls, + message: str, + response: Response, + ) -> Optional[UnexpectedResponseError]: + """ + Creates an UnexpectedResponseError object from the given response and message. + + :arg message: The message to create this error with. + :arg response: The response to create an UnexpectedResponseError from. + :returns: The new UnexpectedResponseError. + """ + + try: + response_json = response.json() + except JSONDecodeError: + response_json = None + + return cls( + message, + status=response.status_code, + json=response_json, + response=response, + ) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 1bbc631b7..dbb45d0df 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -287,23 +287,9 @@ def _api_call( if warning: logger.warning("Received warning from server: {}".format(warning)) - if 399 < response.status_code < 600: - j = None - error_msg = "{}: ".format(response.status_code) - try: - j = response.json() - if "errors" in j.keys(): - for e in j["errors"]: - msg = e.get("reason", "") - field = e.get("field", None) - - error_msg += "{}{}; ".format( - f"[{field}] " if field is not None else "", - msg, - ) - except: - pass - raise ApiError(error_msg, status=response.status_code, json=j) + api_error = ApiError.from_response(response) + if api_error is not None: + raise api_error if response.status_code != 204: j = response.json() diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 1263ee49c..e21c5c4b2 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -434,10 +434,9 @@ def oauth_redirect(): ) if r.status_code != 200: - raise ApiError( - "OAuth token exchange failed", - status=r.status_code, - json=r.json(), + raise ApiError.from_response( + r, + message="OAuth token exchange failed", ) token = r.json()["access_token"] @@ -479,7 +478,7 @@ def refresh_oauth_token(self, refresh_token): ) if r.status_code != 200: - raise ApiError("Refresh failed", r) + raise ApiError.from_response(r, message="Refresh failed") token = r.json()["access_token"] scopes = OAuthScopes.parse(r.json()["scopes"]) @@ -516,5 +515,5 @@ def expire_token(self, token): ) if r.status_code != 200: - raise ApiError("Failed to expire token!", r) + raise ApiError.from_response(r, "Failed to expire token!") return True diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 4777ff1c4..375e5fc03 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -436,8 +436,10 @@ def thumbnail(self, dump_to=None): ) if not result.status_code == 200: - raise ApiError( - "No thumbnail found for OAuthClient {}".format(self.id) + raise ApiError.from_response( + result, + "No thumbnail found for OAuthClient {}".format(self.id), + disable_formatting=True, ) if dump_to: @@ -472,12 +474,9 @@ def set_thumbnail(self, thumbnail): data=thumbnail, ) - if not result.status_code == 200: - errors = [] - j = result.json() - if "errors" in j: - errors = [e["reason"] for e in j["errors"]] - raise ApiError("{}: {}".format(result.status_code, errors), json=j) + api_exc = ApiError.from_response(result) + if api_exc is not None: + raise api_exc return True diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index f835b3f31..548f58f16 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -174,12 +174,9 @@ def upload_attachment(self, attachment: Union[Path, str]): files={"file": f}, ) - if not result.status_code == 200: - errors = [] - j = result.json() - if "errors" in j: - errors = [e["reason"] for e in j["errors"]] - raise ApiError("{}: {}".format(result.status_code, errors), json=j) + api_exc = ApiError.from_response(result) + if api_exc is not None: + raise api_exc return True diff --git a/test/integration/linode_client/test_errors.py b/test/integration/linode_client/test_errors.py new file mode 100644 index 000000000..2c3ab57b5 --- /dev/null +++ b/test/integration/linode_client/test_errors.py @@ -0,0 +1,28 @@ +from linode_api4.errors import ApiError + + +def test_error_404(test_linode_client): + api_exc = None + + try: + test_linode_client.get("/invalid/endpoint") + except ApiError as exc: + api_exc = exc + + assert str(api_exc) == "GET /v4beta/invalid/endpoint: [404] Not found" + + +def test_error_400(test_linode_client): + api_exc = None + + try: + test_linode_client.linode.instance_create( + "g6-fake-plan", "us-fakeregion" + ) + except ApiError as exc: + api_exc = exc + + assert str(api_exc) == ( + "POST /v4beta/linode/instances: [400] type: A valid plan type by that ID was not found; " + "region: region is not valid" + ) diff --git a/test/unit/errors_test.py b/test/unit/errors_test.py new file mode 100644 index 000000000..017c96280 --- /dev/null +++ b/test/unit/errors_test.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace +from unittest import TestCase + +from linode_api4.errors import ApiError, UnexpectedResponseError + + +class ApiErrorTest(TestCase): + def test_from_response(self): + mock_response = SimpleNamespace( + status_code=400, + json=lambda: { + "errors": [ + {"reason": "foo"}, + {"field": "bar", "reason": "oh no"}, + ] + }, + text='{"errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}]}', + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [400] foo; bar: oh no" + assert exc.status == 400 + assert exc.json == { + "errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}] + } + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_non_json_body(self): + mock_response = SimpleNamespace( + status_code=500, + json=lambda: None, + text="foobar", + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [500] foobar" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_empty_body(self): + mock_response = SimpleNamespace( + status_code=500, + json=lambda: None, + text=None, + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [500] N/A" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_no_request(self): + mock_response = SimpleNamespace( + status_code=500, json=lambda: None, text="foobar", request=None + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "[500] foobar" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request is None + + +class UnexpectedResponseErrorTest(TestCase): + def test_from_response(self): + mock_response = SimpleNamespace( + status_code=400, + json=lambda: { + "foo": "bar", + }, + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = UnexpectedResponseError.from_response("foobar", mock_response) + + assert str(exc) == "foobar" + assert exc.status == 400 + assert exc.json == {"foo": "bar"} + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" From 76a2f5b5f9723a009b126b640b6e7ffc400980ef Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Thu, 31 Oct 2024 12:11:54 -0400 Subject: [PATCH 249/379] Added docs links (#470) --- linode_api4/groups/lke.py | 2 +- linode_api4/groups/networking.py | 2 +- linode_api4/groups/nodebalancer.py | 2 +- linode_api4/groups/volume.py | 2 +- linode_api4/objects/lke.py | 8 ++++---- linode_api4/objects/networking.py | 2 +- linode_api4/objects/nodebalancer.py | 2 +- linode_api4/objects/volume.py | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index b60090595..d0de66f37 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -161,7 +161,7 @@ def types(self, *filters): """ Returns a :any:`PaginatedList` of :any:`LKEType` objects that represents a valid LKE type. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 5d49e9bb3..4820b706d 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -354,7 +354,7 @@ def transfer_prices(self, *filters): """ Returns a :any:`PaginatedList` of :any:`NetworkTransferPrice` objects that represents a valid network transfer price. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-network-transfer-prices :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index acc1f07e2..57830c8c4 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -55,7 +55,7 @@ def types(self, *filters): """ Returns a :any:`PaginatedList` of :any:`NodeBalancerType` objects that represents a valid NodeBalancer type. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 3a30de762..6e879c3d6 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -78,7 +78,7 @@ def types(self, *filters): """ Returns a :any:`PaginatedList` of :any:`VolumeType` objects that represents a valid Volume type. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-volume-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 1c2ed3c1a..7ff6b0fd8 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -22,7 +22,7 @@ class LKEType(Base): Currently the LKEType can only be retrieved by listing, i.e.: types = client.lke.types() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-types """ properties = { @@ -338,7 +338,7 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-acl :returns: The cluster's control plane ACL configuration. :rtype: LKEClusterControlPlaneACL @@ -529,7 +529,7 @@ def control_plane_acl_update( NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-lke-cluster-acl :param acl: The ACL configuration to apply to this cluster. :type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any] @@ -560,7 +560,7 @@ def control_plane_acl_delete(self): NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-cluster-acl """ self._client.delete( f"{LKECluster.api_endpoint}/control_plane_acl", model=self diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index c4fff1ac3..613eca21c 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -268,7 +268,7 @@ class NetworkTransferPrice(Base): Currently the NetworkTransferPrice can only be retrieved by listing, i.e.: types = client.networking.transfer_prices() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-network-transfer-prices """ properties = { diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index d038b6998..840d5b965 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -15,7 +15,7 @@ class NodeBalancerType(Base): Currently the NodeBalancerType can only be retrieved by listing, i.e.: types = client.nodebalancers.types() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-types """ properties = { diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 58764e8d7..6d49f72c9 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -11,7 +11,7 @@ class VolumeType(Base): Currently the VolumeType can only be retrieved by listing, i.e.: types = client.volumes.types() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-volume-types """ properties = { From 7dacfcfd7c2ac19a6c93a42ad3b529473ede8164 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:37:24 -0700 Subject: [PATCH 250/379] CI: Notify slack channel on releases (#469) * add release-notify-slack workflow * add release-notify-slack workflow * add workflow dispatch * gha test 1 * gha test 2 * gha test 3 * gha test 4 * gha test 5 * fix payload syntax * change environment variable name * update slack payload for gh test * update slack payload to include link to release notes --- .github/workflows/release-notify-slack.yml | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/release-notify-slack.yml diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml new file mode 100644 index 000000000..aa3d30af4 --- /dev/null +++ b/.github/workflows/release-notify-slack.yml @@ -0,0 +1,30 @@ +name: Notify Dev DX Channel on Release +on: + release: + types: [published] + workflow_dispatch: null + +jobs: + notify: + if: github.repository == 'linode/linode_api4-python' + runs-on: ubuntu-latest + steps: + - name: Notify Slack - Main Message + id: main_message + uses: slackapi/slack-github-action@v1.27.0 + with: + channel-id: ${{ secrets.DEV_DX_SLACK_CHANNEL_ID }} + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*New Release Published: _linode_api4-python_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file From 9ca49372c742e2a9e7ec6c3a4fd2189cb6be49c8 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Thu, 7 Nov 2024 08:41:14 -0800 Subject: [PATCH 251/379] test: Allow integration and unit tests to run against EOL or desired python version (#471) * EOL python version updates * update wording * add workflow_dispatch input to specify python version --- .github/workflows/e2e-test.yml | 14 +++++++++++++- .github/workflows/main.yml | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 848154b55..ccd388242 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -11,6 +11,18 @@ on: description: 'The hash value of the commit' required: false default: '' + python-version: + description: 'Specify Python version to use' + required: false + default: '3.10' + run-eol-python-version: + description: 'Run EOL python version?' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' push: branches: - main @@ -40,7 +52,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: ${{ inputs.run-eol-python-version == 'true' && '3.9' || inputs.python-version }} - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee290360c..f29a4529f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8','3.9','3.10','3.11', '3.12'] + python-version: ['3.9','3.10','3.11', '3.12'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 From 9db9dcc2079e16a5344fcf8e429568eaa7eb1bf7 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:47:59 -0500 Subject: [PATCH 252/379] Configure default Python version using integration-tests job environment (#473) * Configure default Python version using workflow environment * Correct EOL and default Python versions --- .github/workflows/e2e-test.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index ccd388242..1b9488192 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -14,7 +14,6 @@ on: python-version: description: 'Specify Python version to use' required: false - default: '3.10' run-eol-python-version: description: 'Run EOL python version?' required: false @@ -28,11 +27,14 @@ on: - main - dev +env: + DEFAULT_PYTHON_VERSION: "3.9" + EOL_PYTHON_VERSION: "3.8" + EXIT_STATUS: 0 + jobs: integration-tests: runs-on: ubuntu-latest - env: - EXIT_STATUS: 0 steps: - name: Clone Repository with SHA if: ${{ inputs.sha != '' }} @@ -52,7 +54,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: ${{ inputs.run-eol-python-version == 'true' && '3.9' || inputs.python-version }} + python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi From 4fced5b727a13d3ecb7a565a8233008724573236 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:38:12 -0800 Subject: [PATCH 253/379] update warm resizing test case (#474) --- test/integration/models/linode/test_linode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 6d461cdf1..998a0c89a 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -392,7 +392,7 @@ def test_linode_resize_with_migration_type( # there is no resizing state in warm migration anymore hence wait for resizing and poll event test_linode_client.polling.event_poller_create( "linode", "linode_resize", entity_id=linode.id - ).wait_for_next_event_finished(interval=5) + ).wait_for_next_event_finished(interval=5, timeout=500) wait_for_condition( 10, From 25fe52c8b4a49b108b3e9006d98ddaf958490df1 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:58:31 -0800 Subject: [PATCH 254/379] test: Add job in E2E CI to attach firewall to any remaining instances (#468) * add add-fw-to-remaining-instances job to e2e ci workflows * update needs field --- .github/workflows/e2e-test-pr.yml | 72 +++++++++++++++++++++++------- .github/workflows/e2e-test.yml | 74 ++++++++++++++++++++++++------- 2 files changed, 115 insertions(+), 31 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 4d44d48d3..b90ee1796 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -71,14 +71,6 @@ jobs: - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi - - name: Download kubectl and calicoctl for LKE clusters - run: | - curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" - chmod +x calicoctl-linux-amd64 kubectl - mv calicoctl-linux-amd64 /usr/local/bin/calicoctl - mv kubectl /usr/local/bin/kubectl - - name: Install Python SDK run: make dev-install env: @@ -92,13 +84,6 @@ jobs: env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Apply Calico Rules to LKE - if: always() - run: | - cd scripts && ./lke_calico_rules_e2e.sh - env: - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Upload test results if: always() run: | @@ -141,3 +126,60 @@ jobs: conclusion: process.env.conclusion }); return result; + + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-fork-ubuntu] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + + - name: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-fork-ubuntu] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 1b9488192..f8cc52112 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -64,14 +64,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Download kubectl and calicoctl for LKE clusters - run: | - curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" - chmod +x calicoctl-linux-amd64 kubectl - mv calicoctl-linux-amd64 /usr/local/bin/calicoctl - mv kubectl /usr/local/bin/kubectl - - name: Set LINODE_TOKEN run: | echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV @@ -84,13 +76,6 @@ jobs: env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Apply Calico Rules to LKE - if: always() - run: | - cd scripts && ./lke_calico_rules_e2e.sh - env: - LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Upload test results if: always() run: | @@ -106,10 +91,67 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-tests] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + + - name: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ env.LINODE_TOKEN }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-tests] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ env.LINODE_TOKEN }} + notify-slack: runs-on: ubuntu-latest needs: [integration-tests] - if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + if: ${{ (success() || failure()) && github.repository == 'linode/linode_api4-python' }} # Run even if integration tests fail and only on main repository steps: - name: Notify Slack From 96ac044000d0f9c1affd7f26803676ff27503add Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Wed, 13 Nov 2024 07:26:10 -0800 Subject: [PATCH 255/379] Setting token in different job in e2e tests (#475) --- .github/workflows/e2e-test.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index f8cc52112..e02b708e1 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -103,6 +103,10 @@ jobs: fetch-depth: 0 submodules: 'recursive' + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + - name: Download kubectl and calicoctl for LKE clusters run: | curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" @@ -132,6 +136,10 @@ jobs: run: | pip install linode-cli + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + - name: Create Firewall and Attach to Instances run: | FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) From 0987f215c8b2ab4a99e609765d25500c25e2aa2c Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 19 Nov 2024 10:43:13 -0800 Subject: [PATCH 256/379] tests: Update helpers, fix flaky tests, and refactor for consistency (#476) * test function/case refactors and update flaky tests * enable disk encryption test --- e2e_scripts | 2 +- test/integration/conftest.py | 109 ++++++------------ test/integration/helpers.py | 105 ++++------------- .../linode_client/test_linode_client.py | 14 +-- .../models/account/test_account.py | 6 +- test/integration/models/domain/test_domain.py | 2 +- .../models/firewall/test_firewall.py | 7 +- test/integration/models/image/test_image.py | 13 +-- test/integration/models/linode/test_linode.py | 61 ++++------ test/integration/models/lke/test_lke.py | 8 +- .../models/longview/test_longview.py | 5 +- .../models/networking/test_networking.py | 44 ++++--- .../models/nodebalancer/test_nodebalancer.py | 31 +++-- test/integration/models/volume/test_volume.py | 72 +++++++----- 14 files changed, 206 insertions(+), 273 deletions(-) diff --git a/e2e_scripts b/e2e_scripts index b56178520..6b71cb72e 160000 --- a/e2e_scripts +++ b/e2e_scripts @@ -1 +1 @@ -Subproject commit b56178520fae446a0a4f38df6259deb845efa667 +Subproject commit 6b71cb72eb20a18ace82f9e73a0f99fe1141d625 diff --git a/test/integration/conftest.py b/test/integration/conftest.py index cb1305d68..ba4a2ee14 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -2,13 +2,17 @@ import os import random import time +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, +) from typing import Optional, Set import pytest import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import ApiError, PlacementGroupPolicy, PlacementGroupType +from linode_api4 import PlacementGroupPolicy, PlacementGroupType from linode_api4.linode_client import LinodeClient from linode_api4.objects import Region @@ -27,13 +31,6 @@ def get_api_url(): return os.environ.get(ENV_API_URL_NAME, "https://api.linode.com/v4beta") -def get_random_label(): - timestamp = str(time.time_ns())[:-5] - label = "label_" + timestamp - - return label - - def get_regions( client: LinodeClient, capabilities: Optional[Set[str]] = None, @@ -59,7 +56,7 @@ def get_regions( def get_region( - client: LinodeClient, capabilities: Set[str] = None, site_type: str = None + client: LinodeClient, capabilities: Set[str] = None, site_type: str = "core" ): return random.choice(get_regions(client, capabilities, site_type)) @@ -161,14 +158,12 @@ def create_inbound_rule(ipv4_address, ipv6_address): def create_linode(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian12", label=label, firewall=e2e_test_firewall, @@ -183,14 +178,12 @@ def create_linode(test_linode_client, e2e_test_firewall): def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label, firewall=e2e_test_firewall, @@ -271,36 +264,21 @@ def test_domain(test_linode_client): @pytest.fixture(scope="session") def test_volume(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - region = client.regions()[4] - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) volume = client.volume_create(label=label, region=region) yield volume - timeout = 100 # give 100s for volume to be detached before deletion - - start_time = time.time() - - while time.time() - start_time < timeout: - try: - res = volume.delete() - if res: - break - else: - time.sleep(3) - except ApiError as e: - if time.time() - start_time > timeout: - raise e + send_request_when_resource_available(timeout=100, func=volume.delete) @pytest.fixture(scope="session") def test_volume_with_encryption(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) region = get_region(client, {"Block Storage Encryption"}) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) volume = client.volume_create( label=label, region=region, encryption="enabled" @@ -308,28 +286,14 @@ def test_volume_with_encryption(test_linode_client): yield volume - timeout = 100 # give 100s for volume to be detached before deletion - - start_time = time.time() - - while time.time() - start_time < timeout: - try: - res = volume.delete() - if res: - break - else: - time.sleep(3) - except ApiError as e: - if time.time() - start_time > timeout: - raise e + send_request_when_resource_available(timeout=100, func=volume.delete) @pytest.fixture def test_tag(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) tag = client.tag_create(label=label) @@ -342,11 +306,10 @@ def test_tag(test_linode_client): def test_nodebalancer(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) nodebalancer = client.nodebalancer_create( - region=get_region(client), label=label + region=get_region(client, capabilities={"NodeBalancers"}), label=label ) yield nodebalancer @@ -357,8 +320,7 @@ def test_nodebalancer(test_linode_client): @pytest.fixture def test_longview_client(test_linode_client): client = test_linode_client - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) longview_client = client.longview.client_create(label=label) yield longview_client @@ -370,7 +332,8 @@ def test_longview_client(test_linode_client): def test_sshkey(test_linode_client, ssh_key_gen): pub_key = ssh_key_gen[0] client = test_linode_client - key = client.profile.ssh_key_upload(pub_key, "IntTestSDK-sshkey") + key_label = get_test_label(8) + "_key" + key = client.profile.ssh_key_upload(pub_key, key_label) yield key @@ -380,7 +343,7 @@ def test_sshkey(test_linode_client, ssh_key_gen): @pytest.fixture def access_keys_object_storage(test_linode_client): client = test_linode_client - label = "TestSDK-obj-storage-key" + label = get_test_label(length=8) key = client.object_storage.keys_create(label) yield key @@ -398,8 +361,7 @@ def test_firewall(test_linode_client): "inbound_policy": "ACCEPT", } - timestamp = str(time.time_ns()) - label = "firewall_" + timestamp + label = get_test_label(8) + "_firewall" firewall = client.networking.firewall_create( label=label, rules=rules, status="enabled" @@ -413,7 +375,7 @@ def test_firewall(test_linode_client): @pytest.fixture def test_oauth_client(test_linode_client): client = test_linode_client - label = get_random_label() + "_oauth" + label = get_test_label(length=8) + "_oauth" oauth_client = client.account.oauth_client_create( label, "https://localhost/oauth/callback" @@ -428,10 +390,10 @@ def test_oauth_client(test_linode_client): def create_vpc(test_linode_client): client = test_linode_client - timestamp = str(int(time.time())) + label = get_test_label(length=10) vpc = client.vpcs.create( - "pythonsdk-" + timestamp, + label, get_region(test_linode_client, {"VPCs"}), description="test description", ) @@ -455,8 +417,7 @@ def create_vpc_with_subnet_and_linode( ): vpc, subnet = create_vpc_with_subnet - timestamp = str(int(time.time())) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) instance, password = test_linode_client.linode.instance_create( "g6-standard-1", @@ -475,18 +436,18 @@ def create_vpc_with_subnet_and_linode( def create_multiple_vpcs(test_linode_client): client = test_linode_client - timestamp = str(int(time.time_ns() % 10**10)) + label = get_test_label(length=10) - timestamp_2 = str(int(time.time_ns() % 10**10)) + label_2 = get_test_label(length=10) vpc_1 = client.vpcs.create( - "pythonsdk-" + timestamp, + label, get_region(test_linode_client, {"VPCs"}), description="test description", ) vpc_2 = client.vpcs.create( - "pythonsdk-" + timestamp_2, + label_2, get_region(test_linode_client, {"VPCs"}), description="test description", ) @@ -502,10 +463,10 @@ def create_multiple_vpcs(test_linode_client): def create_placement_group(test_linode_client): client = test_linode_client - timestamp = str(int(time.time())) + label = get_test_label(10) pg = client.placement.group_create( - "pythonsdk-" + timestamp, + label, get_region(test_linode_client, {"Placement Group"}), PlacementGroupType.anti_affinity_local, PlacementGroupPolicy.flexible, diff --git a/test/integration/helpers.py b/test/integration/helpers.py index e874ea7e2..0ee9810a8 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -1,116 +1,59 @@ +import random import time +from string import ascii_lowercase from typing import Callable -from linode_api4 import PaginatedList from linode_api4.errors import ApiError -from linode_api4.linode_client import LinodeClient -def get_test_label(): - unique_timestamp = str(time.time_ns())[:-3] - label = "test_" + unique_timestamp - return label - - -def delete_instance_with_test_kw(paginated_list: PaginatedList): - for i in paginated_list: - try: - if hasattr(i, "label"): - label = getattr(i, "label") - if "IntTestSDK" in str(label): - i.delete() - elif "lke" in str(label): - iso_created_date = getattr(i, "created") - created_time = int( - time.mktime(iso_created_date.timetuple()) - ) - timestamp = int(time.time()) - if (timestamp - created_time) < 86400: - i.delete() - elif hasattr(i, "domain"): - domain = getattr(i, "domain") - if "IntTestSDK" in domain: - i.delete() - except AttributeError as e: - if "IntTestSDK" in str(i.__dict__): - i.delete() - - -def delete_all_test_instances(client: LinodeClient): - tags = client.tags() - linodes = client.linode.instances() - images = client.images() - volumes = client.volumes() - nodebalancers = client.nodebalancers() - domains = client.domains() - longview_clients = client.longview.clients() - clusters = client.lke.clusters() - firewalls = client.networking.firewalls() - - delete_instance_with_test_kw(tags) - delete_instance_with_test_kw(linodes) - delete_instance_with_test_kw(images) - delete_instance_with_test_kw(volumes) - delete_instance_with_test_kw(nodebalancers) - delete_instance_with_test_kw(domains) - delete_instance_with_test_kw(longview_clients) - delete_instance_with_test_kw(clusters) - delete_instance_with_test_kw(firewalls) +def get_test_label(length: int = 8): + return "".join(random.choice(ascii_lowercase) for i in range(length)) def wait_for_condition( interval: int, timeout: int, condition: Callable, *args ) -> object: - start_time = time.time() - while True: - if condition(*args): - break - - if time.time() - start_time > timeout: - raise TimeoutError("Wait for condition timeout error") - + end_time = time.time() + timeout + while time.time() < end_time: + result = condition(*args) + if result: + return result time.sleep(interval) + raise TimeoutError( + f"Timeout Error: resource not available in {timeout} seconds" + ) # Retry function to help in case of requests sending too quickly before instance is ready def retry_sending_request( - retries: int, condition: Callable, *args, **kwargs + retries: int, condition: Callable, *args, backoff: int = 5, **kwargs ) -> object: - curr_t = 0 - while curr_t < retries: + for attempt in range(1, retries + 1): try: - curr_t += 1 - res = condition(*args, **kwargs) - return res + return condition(*args, **kwargs) except ApiError: - if curr_t >= retries: - raise ApiError - time.sleep(5) + if attempt == retries: + raise ApiError( + "Api Error: Failed after all retry attempts" + ) from None + time.sleep(backoff) def send_request_when_resource_available( timeout: int, func: Callable, *args, **kwargs ) -> object: start_time = time.time() + retry_statuses = {400, 500} while True: try: - res = func(*args, **kwargs) - return res + return func(*args, **kwargs) except ApiError as e: - if ( - e.status == 400 - or e.status == 500 - or "Please try again later" in str(e.__dict__) - ): + if e.status in retry_statuses or "Please try again later" in str(e): if time.time() - start_time > timeout: raise TimeoutError( - "Timeout Error: resource is not available in " - + str(timeout) - + " seconds" + f"Timeout Error: resource not available in {timeout} seconds" ) time.sleep(10) else: raise e - - return res diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 105535211..2802c90f9 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -12,15 +12,13 @@ @pytest.fixture(scope="session") def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client - chosen_region = get_region( - client, {"Kubernetes", "NodeBalancers"}, "core" - ).id + region = get_region(client, {"Kubernetes", "NodeBalancers"}, "core").id label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label, firewall=e2e_test_firewall, @@ -233,11 +231,11 @@ def test_get_account_settings(test_linode_client): # LinodeGroupTests def test_create_linode_instance_without_image(test_linode_client): client = test_linode_client - chosen_region = get_region(client, {"Linodes"}, "core").id + region = get_region(client, {"Linodes"}, "core").id label = get_test_label() linode_instance = client.linode.instance_create( - "g6-nanode-1", chosen_region, label=label + "g6-nanode-1", region, label=label ) assert linode_instance.label == label @@ -257,12 +255,12 @@ def test_create_linode_instance_with_image(setup_client_and_linode): def test_create_linode_with_interfaces(test_linode_client): client = test_linode_client - chosen_region = get_region(client, {"Vlans", "Linodes"}).id + region = get_region(client, {"Vlans", "Linodes"}, site_type="core").id label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, label=label, image="linode/debian10", interfaces=[ diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index ab20ee079..8bdc8c60e 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -1,5 +1,6 @@ import time from datetime import datetime +from test.integration.conftest import get_region from test.integration.helpers import get_test_label import pytest @@ -64,13 +65,12 @@ def test_get_account_settings(test_linode_client): def test_latest_get_event(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label, firewall=e2e_test_firewall, diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index cf5a54710..36ecbb0dc 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -42,7 +42,7 @@ def get_zone_file_view(): def test_clone(test_linode_client, test_domain): domain = test_linode_client.load(Domain, test_domain.id) timestamp = str(time.time_ns()) - dom = "example.clone-" + timestamp + "-IntTestSDK.org" + dom = "example.clone-" + timestamp + "-inttestsdk.org" domain.clone(dom) ds = test_linode_client.domains() diff --git a/test/integration/models/firewall/test_firewall.py b/test/integration/models/firewall/test_firewall.py index 7f907cc2f..6a9f6f079 100644 --- a/test/integration/models/firewall/test_firewall.py +++ b/test/integration/models/firewall/test_firewall.py @@ -1,4 +1,5 @@ import time +from test.integration.conftest import get_region from test.integration.helpers import get_test_label import pytest @@ -9,12 +10,11 @@ @pytest.fixture(scope="session") def linode_fw(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", region, image="linode/debian10", label=label ) yield linode_instance @@ -80,6 +80,5 @@ def test_get_device(test_linode_client, test_firewall, linode_fw): FirewallDevice, firewall.devices.first().id, firewall.id ) - assert "test_" in firewall_device.entity.label assert firewall_device.entity.type == "linode" assert "/v4/linode/instances/" in firewall_device.entity.url diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 4c2aa77d2..94c819709 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -1,9 +1,6 @@ from io import BytesIO from test.integration.conftest import get_region, get_regions -from test.integration.helpers import ( - delete_instance_with_test_kw, - get_test_label, -) +from test.integration.helpers import get_test_label import polling import pytest @@ -15,7 +12,11 @@ def image_upload_url(test_linode_client): label = get_test_label() + "_image" - region = get_region(test_linode_client, site_type="core") + region = get_region( + test_linode_client, + capabilities={"Linodes", "Object Storage"}, + site_type="core", + ) test_linode_client.image_create_upload( label, region.id, "integration test image upload" @@ -26,8 +27,6 @@ def image_upload_url(test_linode_client): yield image image.delete() - images = test_linode_client.images() - delete_instance_with_test_kw(images) @pytest.fixture(scope="session") diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 998a0c89a..a8ba2b21e 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -25,8 +25,7 @@ @pytest.fixture(scope="session") def linode_with_volume_firewall(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() rules = { @@ -38,7 +37,7 @@ def linode_with_volume_firewall(test_linode_client): linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label + "_modlinode", ) @@ -71,14 +70,12 @@ def linode_with_volume_firewall(test_linode_client): @pytest.fixture(scope="session") def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label, firewall=e2e_test_firewall, @@ -92,13 +89,12 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): @pytest.fixture def linode_for_disk_tests(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/alpine3.19", label=label + "_long_tests", firewall=e2e_test_firewall, @@ -126,12 +122,12 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): @pytest.fixture def linode_with_block_storage_encryption(test_linode_client, e2e_test_firewall): client = test_linode_client - chosen_region = get_region(client, {"Linodes", "Block Storage Encryption"}) + region = get_region(client, {"Linodes", "Block Storage Encryption"}) label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/alpine3.19", label=label + "block-storage-encryption", firewall=e2e_test_firewall, @@ -145,13 +141,12 @@ def linode_with_block_storage_encryption(test_linode_client, e2e_test_firewall): @pytest.fixture def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label + "_long_tests", firewall=e2e_test_firewall, @@ -167,15 +162,14 @@ def linode_with_disk_encryption(test_linode_client, request): client = test_linode_client target_region = get_region(client, {"Disk Encryption"}) - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + label = get_test_label(length=8) disk_encryption = request.param linode_instance, password = client.linode.instance_create( "g6-nanode-1", target_region, - image="linode/ubuntu23.04", + image="linode/ubuntu23.10", label=label, booted=False, disk_encryption=disk_encryption, @@ -215,14 +209,12 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - # TODO(LDE): Uncomment once LDE is available - # chosen_region = get_region(client, {"Disk Encryption"}) - chosen_region = get_region(client) + region = get_region(client, {"Disk Encryption"}) label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( - "g6-nanode-1", chosen_region, image="linode/debian10", label=label + "g6-nanode-1", region, image="linode/debian10", label=label ) wait_for_condition(10, 100, get_status, linode, "running") @@ -231,8 +223,7 @@ def test_linode_rebuild(test_linode_client): 3, linode.rebuild, "linode/debian10", - # TODO(LDE): Uncomment once LDE is available - # disk_encryption=InstanceDiskEncryptionType.disabled, + disk_encryption=InstanceDiskEncryptionType.disabled, ) wait_for_condition(10, 100, get_status, linode, "rebuilding") @@ -240,8 +231,7 @@ def test_linode_rebuild(test_linode_client): assert linode.status == "rebuilding" assert linode.image.id == "linode/debian10" - # TODO(LDE): Uncomment once LDE is available - # assert linode.disk_encryption == InstanceDiskEncryptionType.disabled + assert linode.disk_encryption == InstanceDiskEncryptionType.disabled wait_for_condition(10, 300, get_status, linode, "running") @@ -276,13 +266,12 @@ def test_update_linode(create_linode): def test_delete_linode(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian10", label=label + "_linode", ) @@ -372,6 +361,7 @@ def test_linode_resize_with_class( assert linode.status == "running" +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_resize_with_migration_type( test_linode_client, create_linode_for_long_running_tests, @@ -433,7 +423,7 @@ def test_linode_firewalls(linode_with_volume_firewall): firewalls = linode.firewalls() assert len(firewalls) > 0 - assert "test" in firewalls[0].label + assert "firewall" in firewalls[0].label def test_linode_volumes(linode_with_volume_firewall): @@ -442,11 +432,9 @@ def test_linode_volumes(linode_with_volume_firewall): volumes = linode.volumes() assert len(volumes) > 0 - assert "test" in volumes[0].label + assert "_volume" in volumes[0].label -# TODO(LDE): Remove skip once LDE is available -@pytest.mark.skip("LDE is not currently enabled") @pytest.mark.parametrize( "linode_with_disk_encryption", ["disabled"], indirect=True ) @@ -541,13 +529,12 @@ def test_linode_ips(create_linode): def test_linode_initate_migration(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() + "_migration" linode, _ = client.linode.instance_create( "g6-nanode-1", - chosen_region, + region, image="linode/debian12", label=label, firewall=e2e_test_firewall, diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index bd0692dcc..4a3ba6c7e 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -23,6 +23,7 @@ LKENodePoolTaint, LKEType, ) +from linode_api4.objects.linode import InstanceDiskEncryptionType @pytest.fixture(scope="session") @@ -30,9 +31,7 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - # TODO(LDE): Uncomment once LDE is available - # region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) - region = get_region(test_linode_client, {"Kubernetes"}) + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -146,8 +145,7 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) - # TODO(LDE): Uncomment once LDE is available - # assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled def test_cluster_dashboard_url_view(lke_cluster): diff --git a/test/integration/models/longview/test_longview.py b/test/integration/models/longview/test_longview.py index f04875e63..6a6855460 100644 --- a/test/integration/models/longview/test_longview.py +++ b/test/integration/models/longview/test_longview.py @@ -1,5 +1,6 @@ import re import time +from test.integration.helpers import get_test_label import pytest @@ -22,7 +23,7 @@ def test_update_longview_label(test_linode_client, test_longview_client): longview = test_linode_client.load(LongviewClient, test_longview_client.id) old_label = longview.label - label = "updated_longview_label" + label = get_test_label(10) longview.label = label @@ -33,7 +34,7 @@ def test_update_longview_label(test_linode_client, test_longview_client): def test_delete_client(test_linode_client, test_longview_client): client = test_linode_client - label = "TestSDK-longview" + label = get_test_label(length=8) longview_client = client.longview.client_create(label=label) time.sleep(5) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index a52f38ef2..430bd94b9 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,32 +1,36 @@ +from test.integration.conftest import ( + get_api_ca_file, + get_api_url, + get_region, + get_token, +) from test.integration.helpers import get_test_label import pytest +from linode_api4 import LinodeClient from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress from linode_api4.objects.networking import NetworkTransferPrice, Price - -@pytest.mark.smoke -def test_get_networking_rules(test_linode_client, test_firewall): - firewall = test_linode_client.load(Firewall, test_firewall.id) - - rules = firewall.get_rules() - - assert "inbound" in str(rules) - assert "inbound_policy" in str(rules) - assert "outbound" in str(rules) - assert "outbound_policy" in str(rules) +TEST_REGION = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall"}, + site_type="core", +) def create_linode(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + label = get_test_label() linode_instance, _ = client.linode.instance_create( "g6-nanode-1", - chosen_region, + TEST_REGION, image="linode/debian12", label=label, ) @@ -52,6 +56,18 @@ def create_linode_to_be_shared_with_ips(test_linode_client): linode.delete() +@pytest.mark.smoke +def test_get_networking_rules(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) + + rules = firewall.get_rules() + + assert "inbound" in str(rules) + assert "inbound_policy" in str(rules) + assert "outbound" in str(rules) + assert "outbound_policy" in str(rules) + + @pytest.mark.smoke def test_ip_addresses_share( test_linode_client, diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 5581c9029..d8a8a53b1 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -1,8 +1,15 @@ import re +from test.integration.conftest import ( + get_api_ca_file, + get_api_url, + get_region, + get_token, +) +from test.integration.helpers import get_test_label import pytest -from linode_api4 import ApiError +from linode_api4 import ApiError, LinodeClient from linode_api4.objects import ( NodeBalancerConfig, NodeBalancerNode, @@ -10,17 +17,25 @@ RegionPrice, ) +TEST_REGION = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall", "NodeBalancers"}, + site_type="core", +) + @pytest.fixture(scope="session") def linode_with_private_ip(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - label = "linode_with_privateip" + label = get_test_label(8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + TEST_REGION, image="linode/debian10", label=label, private_ip=True, @@ -35,12 +50,10 @@ def linode_with_private_ip(test_linode_client, e2e_test_firewall): @pytest.fixture(scope="session") def create_nb_config(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - label = "nodebalancer_test" + label = get_test_label(8) nb = client.nodebalancer_create( - region=chosen_region, label=label, firewall=e2e_test_firewall.id + region=TEST_REGION, label=label, firewall=e2e_test_firewall.id ) config = nb.config_create() diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 19bc55c26..6588d92a7 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -1,28 +1,54 @@ import time -from test.integration.conftest import get_token +from test.integration.conftest import ( + get_api_ca_file, + get_api_url, + get_region, + get_token, +) from test.integration.helpers import ( get_test_label, retry_sending_request, + send_request_when_resource_available, wait_for_condition, ) import pytest -from linode_api4 import ApiError, LinodeClient +from linode_api4 import LinodeClient from linode_api4.objects import RegionPrice, Volume, VolumeType +TEST_REGION = get_region( + LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ), + {"Linodes", "Cloud Firewall"}, + site_type="core", +) + + +@pytest.fixture(scope="session") +def test_volume(test_linode_client): + client = test_linode_client + label = get_test_label(length=8) + + volume = client.volume_create(label=label, region=TEST_REGION) + + yield volume + + send_request_when_resource_available(timeout=100, func=volume.delete) + @pytest.fixture(scope="session") def linode_for_volume(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] - timestamp = str(time.time_ns()) - label = "TestSDK-" + timestamp + + label = get_test_label(length=8) linode_instance, password = client.linode.instance_create( "g6-nanode-1", - chosen_region, + TEST_REGION, image="linode/debian10", label=label, firewall=e2e_test_firewall, @@ -30,25 +56,17 @@ def linode_for_volume(test_linode_client, e2e_test_firewall): yield linode_instance - timeout = 100 # give 100s for volume to be detached before deletion - - start_time = time.time() - - while time.time() - start_time < timeout: - try: - res = linode_instance.delete() - - if res: - break - else: - time.sleep(3) - except ApiError as e: - if time.time() - start_time > timeout: - raise e + send_request_when_resource_available( + timeout=100, func=linode_instance.delete + ) def get_status(volume: Volume, status: str): - client = LinodeClient(token=get_token()) + client = LinodeClient( + token=get_token(), + base_url=get_api_url(), + ca_path=get_api_ca_file(), + ) volume = client.load(Volume, volume.id) return volume.status == status @@ -71,15 +89,15 @@ def test_get_volume_with_encryption( def test_update_volume_tag(test_linode_client, test_volume): volume = test_volume - tag_1 = "volume_test_tag1" - tag_2 = "volume_test_tag2" + tag_1 = get_test_label(10) + tag_2 = get_test_label(10) volume.tags = [tag_1, tag_2] volume.save() volume = test_linode_client.load(Volume, test_volume.id) - assert [tag_1, tag_2] == volume.tags + assert all(tag in volume.tags for tag in [tag_1, tag_2]) def test_volume_resize(test_linode_client, test_volume): @@ -113,7 +131,7 @@ def test_attach_volume_to_linode( volume = test_volume linode = linode_for_volume - res = retry_sending_request(5, volume.attach, linode.id) + res = retry_sending_request(5, volume.attach, linode.id, backoff=30) assert res From 3e7524c41523f0a346a2980e5d4cf385faed7dc2 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:12:20 -0500 Subject: [PATCH 257/379] pg migration (#477) --- linode_api4/objects/placement.py | 23 ++++++- test/fixtures/placement_groups.json | 14 ++++- test/fixtures/placement_groups_123.json | 14 ++++- .../models/placement/test_placement.py | 63 ++++++++++++++++++- test/unit/objects/placement_test.py | 3 + 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py index aa894af33..e436cf701 100644 --- a/linode_api4/objects/placement.py +++ b/linode_api4/objects/placement.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Union +from typing import List, Optional, Union from linode_api4.objects.base import Base, Property from linode_api4.objects.linode import Instance @@ -34,6 +34,26 @@ class PlacementGroupMember(JSONObject): is_compliant: bool = False +@dataclass +class MigratedInstance(JSONObject): + """ + The ID for a compute instance being migrated into or out of the placement group. + """ + + linode_id: int = 0 + + +@dataclass +class PlacementGroupMigrations(JSONObject): + """ + Any compute instances that are being migrated to or from the placement group. + Returns an empty object if no migrations are taking place. + """ + + inbound: Optional[List[MigratedInstance]] = None + outbound: Optional[List[MigratedInstance]] = None + + class PlacementGroup(Base): """ NOTE: Placement Groups may not currently be available to all users. @@ -54,6 +74,7 @@ class PlacementGroup(Base): "placement_group_policy": Property(), "is_compliant": Property(), "members": Property(json_object=PlacementGroupMember), + "migrations": Property(json_object=PlacementGroupMigrations), } def assign( diff --git a/test/fixtures/placement_groups.json b/test/fixtures/placement_groups.json index 758fc8521..bf05f9936 100644 --- a/test/fixtures/placement_groups.json +++ b/test/fixtures/placement_groups.json @@ -12,7 +12,19 @@ "linode_id": 123, "is_compliant": true } - ] + ], + "migrations": { + "inbound": [ + { + "linode_id": 123 + } + ], + "outbound": [ + { + "linode_id": 456 + } + ] + } } ], "page": 1, diff --git a/test/fixtures/placement_groups_123.json b/test/fixtures/placement_groups_123.json index 453e9fd5f..c7a9cab27 100644 --- a/test/fixtures/placement_groups_123.json +++ b/test/fixtures/placement_groups_123.json @@ -10,5 +10,17 @@ "linode_id": 123, "is_compliant": true } - ] + ], + "migrations": { + "inbound": [ + { + "linode_id": 123 + } + ], + "outbound": [ + { + "linode_id": 456 + } + ] + } } \ No newline at end of file diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py index db570aa9e..af853a2ea 100644 --- a/test/integration/models/placement/test_placement.py +++ b/test/integration/models/placement/test_placement.py @@ -1,6 +1,18 @@ +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, +) + import pytest -from linode_api4 import PlacementGroup +from linode_api4 import ( + MigratedInstance, + MigrationType, + PlacementGroup, + PlacementGroupPolicy, + PlacementGroupType, +) @pytest.mark.smoke @@ -48,3 +60,52 @@ def test_pg_assignment(test_linode_client, create_placement_group_with_linode): assert pg.members[0].linode_id == inst.id assert inst.placement_group.id == pg.id + + +def test_pg_migration( + test_linode_client, e2e_test_firewall, create_placement_group +): + """ + Tests that an instance can be migrated into and our of PGs successfully. + """ + client = test_linode_client + + label = get_test_label(10) + + pg_outbound = client.placement.group_create( + label, + get_region(test_linode_client, {"Placement Group"}), + PlacementGroupType.anti_affinity_local, + PlacementGroupPolicy.flexible, + ) + + linode = client.linode.instance_create( + "g6-nanode-1", + pg_outbound.region, + label=create_placement_group.label, + placement_group=pg_outbound, + ) + + pg_inbound = create_placement_group + + # Says it could take up to ~6 hrs for migration to fully complete + send_request_when_resource_available( + 300, + linode.initiate_migration, + placement_group=pg_inbound.id, + migration_type=MigrationType.COLD, + region=pg_inbound.region, + ) + + pg_inbound = test_linode_client.load(PlacementGroup, pg_inbound.id) + pg_outbound = test_linode_client.load(PlacementGroup, pg_outbound.id) + + assert pg_inbound.migrations.inbound[0] == MigratedInstance( + linode_id=linode.id + ) + assert pg_outbound.migrations.outbound[0] == MigratedInstance( + linode_id=linode.id + ) + + linode.delete() + pg_outbound.delete() diff --git a/test/unit/objects/placement_test.py b/test/unit/objects/placement_test.py index 71d171644..4e5960e7b 100644 --- a/test/unit/objects/placement_test.py +++ b/test/unit/objects/placement_test.py @@ -2,6 +2,7 @@ from linode_api4 import PlacementGroupPolicy from linode_api4.objects import ( + MigratedInstance, PlacementGroup, PlacementGroupMember, PlacementGroupType, @@ -116,3 +117,5 @@ def validate_pg_123(self, pg: PlacementGroup): assert pg.members[0] == PlacementGroupMember( linode_id=123, is_compliant=True ) + assert pg.migrations.inbound[0] == MigratedInstance(linode_id=123) + assert pg.migrations.outbound[0] == MigratedInstance(linode_id=456) From 564f90f5abb93c25b4a4911639d11091dfc2e61e Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:33:42 -0500 Subject: [PATCH 258/379] rm la (#478) --- linode_api4/objects/image.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 931ed4a31..1215c422c 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -64,12 +64,8 @@ class Image(Base): def replicate(self, regions: Union[List[str], List[Region]]): """ - NOTE: Image replication may not currently be available to all users. - Replicate the image to other regions. - Note: Image replication may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-replicate-image :param regions: A list of regions that the customer wants to replicate this image in. From 65b1ea5c37eeaf87868d2ae7952de7f49ce8c01c Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:20:03 -0800 Subject: [PATCH 259/379] update duplicate label in pg test (#481) --- test/integration/models/placement/test_placement.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/integration/models/placement/test_placement.py b/test/integration/models/placement/test_placement.py index af853a2ea..21c6519f5 100644 --- a/test/integration/models/placement/test_placement.py +++ b/test/integration/models/placement/test_placement.py @@ -70,10 +70,12 @@ def test_pg_migration( """ client = test_linode_client - label = get_test_label(10) + label_pg = get_test_label(10) + + label_instance = get_test_label(10) pg_outbound = client.placement.group_create( - label, + label_pg, get_region(test_linode_client, {"Placement Group"}), PlacementGroupType.anti_affinity_local, PlacementGroupPolicy.flexible, @@ -82,7 +84,7 @@ def test_pg_migration( linode = client.linode.instance_create( "g6-nanode-1", pg_outbound.region, - label=create_placement_group.label, + label=label_instance, placement_group=pg_outbound, ) From be1017b7a8dc999964743b68de53d08867556f71 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:59:27 -0500 Subject: [PATCH 260/379] Require Python >= 3.9 (#482) --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b8b57880b..5098027af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "linode_api4" authors = [{ name = "Linode", email = "devs@linode.com" }] description = "The official Python SDK for Linode API v4" readme = "README.rst" -requires-python = ">=3.8" +requires-python = ">=3.9" keywords = [ "akamai", "Akamai Connected Cloud", @@ -25,7 +25,6 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From 40b88cb02a6b1f58048635218c02354b415193e9 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 13 Dec 2024 13:59:43 -0500 Subject: [PATCH 261/379] Make use of `_flatten_request_body_recursive(...)` wherever possible (#484) * Make use of _flatten_request_body_recursive(...) wherever possible * Drop database changes --- linode_api4/groups/image.py | 10 ++- linode_api4/groups/linode.py | 119 +++++++++++++++------------------- linode_api4/groups/volume.py | 14 ++-- linode_api4/objects/linode.py | 89 +++++++++++++------------ linode_api4/objects/volume.py | 26 ++++---- 5 files changed, 124 insertions(+), 134 deletions(-) diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index e644dc169..fda56fb0a 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -4,7 +4,8 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Disk, Image +from linode_api4.objects import Disk, Image +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.util import drop_null_keys @@ -58,7 +59,7 @@ def create( :rtype: Image """ params = { - "disk_id": disk.id if issubclass(type(disk), Base) else disk, + "disk_id": disk, "label": label, "description": description, "tags": tags, @@ -67,7 +68,10 @@ def create( if cloud_init: params["cloud_init"] = cloud_init - result = self.client.post("/images", data=drop_null_keys(params)) + result = self.client.post( + "/images", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index da3ba501d..48f0d43b6 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,25 +1,29 @@ import base64 import os from collections.abc import Iterable -from typing import Optional, Union +from typing import Any, Dict, Optional, Union -from linode_api4 import InstanceDiskEncryptionType from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - Base, ConfigInterface, Firewall, - Image, Instance, + InstanceDiskEncryptionType, Kernel, + PlacementGroup, StackScript, Type, ) +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.filtering import Filter -from linode_api4.objects.linode import _expand_placement_group_assignment -from linode_api4.paginated_list import PaginatedList +from linode_api4.objects.linode import ( + Backup, + InstancePlacementGroupAssignment, + _expand_placement_group_assignment, +) +from linode_api4.util import drop_null_keys class LinodeGroup(Group): @@ -135,9 +139,20 @@ def instance_create( region, image=None, authorized_keys=None, + firewall: Optional[Union[Firewall, int]] = None, + backup: Optional[Union[Backup, int]] = None, + stackscript: Optional[Union[StackScript, int]] = None, disk_encryption: Optional[ Union[InstanceDiskEncryptionType, str] ] = None, + placement_group: Optional[ + Union[ + InstancePlacementGroupAssignment, + PlacementGroup, + Dict[str, Any], + int, + ] + ] = None, **kwargs, ): """ @@ -290,65 +305,45 @@ def instance_create( This usually indicates that you are using an outdated library. """ + ret_pass = None if image and not "root_pass" in kwargs: ret_pass = Instance.generate_root_password() kwargs["root_pass"] = ret_pass - authorized_keys = load_and_validate_keys(authorized_keys) - - if "stackscript" in kwargs: - # translate stackscripts - kwargs["stackscript_id"] = ( - kwargs["stackscript"].id - if issubclass(type(kwargs["stackscript"]), Base) - else kwargs["stackscript"] - ) - del kwargs["stackscript"] - - if "backup" in kwargs: - # translate backups - kwargs["backup_id"] = ( - kwargs["backup"].id - if issubclass(type(kwargs["backup"]), Base) - else kwargs["backup"] - ) - del kwargs["backup"] - - if "firewall" in kwargs: - fw = kwargs.pop("firewall") - kwargs["firewall_id"] = fw.id if isinstance(fw, Firewall) else fw - - if "interfaces" in kwargs: - interfaces = kwargs.get("interfaces") - if interfaces is not None and isinstance(interfaces, Iterable): - kwargs["interfaces"] = [ - i._serialize() if isinstance(i, ConfigInterface) else i - for i in interfaces - ] - - if "placement_group" in kwargs: - kwargs["placement_group"] = _expand_placement_group_assignment( - kwargs.get("placement_group") - ) + interfaces = kwargs.get("interfaces", None) + if interfaces is not None and isinstance(interfaces, Iterable): + kwargs["interfaces"] = [ + i._serialize() if isinstance(i, ConfigInterface) else i + for i in interfaces + ] params = { - "type": ltype.id if issubclass(type(ltype), Base) else ltype, - "region": region.id if issubclass(type(region), Base) else region, - "image": ( - (image.id if issubclass(type(image), Base) else image) - if image + "type": ltype, + "region": region, + "image": image, + "authorized_keys": load_and_validate_keys(authorized_keys), + # These will automatically be flattened below + "firewall_id": firewall, + "backup_id": backup, + "stackscript_id": stackscript, + # Special cases + "disk_encryption": ( + str(disk_encryption) if disk_encryption else None + ), + "placement_group": ( + _expand_placement_group_assignment(placement_group) + if placement_group else None ), - "authorized_keys": authorized_keys, } - if disk_encryption is not None: - params["disk_encryption"] = str(disk_encryption) - params.update(kwargs) - result = self.client.post("/linode/instances", data=params) + result = self.client.post( + "/linode/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if not "id" in result: raise UnexpectedResponseError( @@ -421,19 +416,6 @@ def stackscript_create( :returns: The new StackScript :rtype: StackScript """ - image_list = None - if type(images) is list or type(images) is PaginatedList: - image_list = [ - d.id if issubclass(type(d), Base) else d for d in images - ] - elif type(images) is Image: - image_list = [images.id] - elif type(images) is str: - image_list = [images] - else: - raise ValueError( - "images must be a list of Images or a single Image" - ) script_body = script if not script.startswith("#!"): @@ -448,14 +430,17 @@ def stackscript_create( params = { "label": label, - "images": image_list, + "images": images, "is_public": public, "script": script_body, "description": desc if desc else "", } params.update(kwargs) - result = self.client.post("/linode/stackscripts", data=params) + result = self.client.post( + "/linode/stackscripts", + data=_flatten_request_body_recursive(params), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 6e879c3d6..39d0aeaaa 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -1,6 +1,7 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Volume, VolumeType +from linode_api4.objects import Volume, VolumeType +from linode_api4.objects.base import _flatten_request_body_recursive class VolumeGroup(Group): @@ -57,14 +58,15 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): params = { "label": label, "size": size, - "region": region.id if issubclass(type(region), Base) else region, - "linode_id": ( - linode.id if issubclass(type(linode), Base) else linode - ), + "region": region, + "linode_id": linode, } params.update(kwargs) - result = self.client.post("/volumes", data=params) + result = self.client.post( + "/volumes", + data=_flatten_request_body_recursive(params), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index cb5c9d9af..8fe71bb7d 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -8,10 +8,14 @@ from typing import Any, Dict, List, Optional, Union from urllib import parse -from linode_api4 import util from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects.base import Base, MappedObject, Property +from linode_api4.objects.base import ( + Base, + MappedObject, + Property, + _flatten_request_body_recursive, +) from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.image import Image @@ -26,6 +30,7 @@ from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation @@ -96,14 +101,14 @@ def restore_to(self, linode, **kwargs): """ d = { - "linode_id": ( - linode.id if issubclass(type(linode), Base) else linode - ), + "linode_id": linode, } d.update(kwargs) self._client.post( - "{}/restore".format(Backup.api_endpoint), model=self, data=d + "{}/restore".format(Backup.api_endpoint), + model=self, + data=_flatten_request_body_recursive(d), ) return True @@ -1063,8 +1068,6 @@ def resize( :rtype: bool """ - new_type = new_type.id if issubclass(type(new_type), Base) else new_type - params = { "type": new_type, "allow_auto_disk_resize": allow_auto_disk_resize, @@ -1073,7 +1076,9 @@ def resize( params.update(kwargs) resp = self._client.post( - "{}/resize".format(Instance.api_endpoint), model=self, data=params + "{}/resize".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(params), ) if "error" in resp: @@ -1189,7 +1194,7 @@ def config_create( param_interfaces.append(interface) params = { - "kernel": kernel.id if issubclass(type(kernel), Base) else kernel, + "kernel": kernel, "label": ( label if label @@ -1201,7 +1206,9 @@ def config_create( params.update(kwargs) result = self._client.post( - "{}/configs".format(Instance.api_endpoint), model=self, data=params + "{}/configs".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(params), ) self.invalidate() @@ -1280,6 +1287,7 @@ def disk_create( "filesystem": filesystem, "authorized_keys": authorized_keys, "authorized_users": authorized_users, + "stackscript_id": stackscript, } if disk_encryption is not None: @@ -1288,20 +1296,18 @@ def disk_create( if image: params.update( { - "image": ( - image.id if issubclass(type(image), Base) else image - ), + "image": image, "root_pass": root_pass, } ) - if stackscript: - params["stackscript_id"] = stackscript.id - if stackscript_args: - params["stackscript_data"] = stackscript_args + if stackscript_args: + params["stackscript_data"] = stackscript_args result = self._client.post( - "{}/disks".format(Instance.api_endpoint), model=self, data=params + "{}/disks".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) self.invalidate() @@ -1461,18 +1467,20 @@ def rebuild( authorized_keys = load_and_validate_keys(authorized_keys) params = { - "image": image.id if issubclass(type(image), Base) else image, + "image": image, "root_pass": root_pass, "authorized_keys": authorized_keys, + "disk_encryption": ( + str(disk_encryption) if disk_encryption else None + ), } - if disk_encryption is not None: - params["disk_encryption"] = str(disk_encryption) - params.update(kwargs) result = self._client.post( - "{}/rebuild".format(Instance.api_endpoint), model=self, data=params + "{}/rebuild".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) if not "id" in result: @@ -1597,7 +1605,7 @@ def initiate_migration( """ params = { - "region": region.id if issubclass(type(region), Base) else region, + "region": region, "upgrade": upgrade, "type": migration_type, "placement_group": _expand_placement_group_assignment( @@ -1605,10 +1613,10 @@ def initiate_migration( ), } - util.drop_null_keys(params) - self._client.post( - "{}/migrate".format(Instance.api_endpoint), model=self, data=params + "{}/migrate".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) def firewalls(self): @@ -1740,21 +1748,12 @@ def clone( if not isinstance(disks, list) and not isinstance(disks, PaginatedList): disks = [disks] - cids = [c.id if issubclass(type(c), Base) else c for c in configs] - dids = [d.id if issubclass(type(d), Base) else d for d in disks] - params = { - "linode_id": ( - to_linode.id if issubclass(type(to_linode), Base) else to_linode - ), - "region": region.id if issubclass(type(region), Base) else region, - "type": ( - instance_type.id - if issubclass(type(instance_type), Base) - else instance_type - ), - "configs": cids if cids else None, - "disks": dids if dids else None, + "linode_id": to_linode, + "region": region, + "type": instance_type, + "configs": configs, + "disks": disks, "label": label, "group": group, "with_backups": with_backups, @@ -1763,10 +1762,10 @@ def clone( ), } - util.drop_null_keys(params) - result = self._client.post( - "{}/clone".format(Instance.api_endpoint), model=self, data=params + "{}/clone".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), ) if not "id" in result: diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 6d49f72c9..cda9932ab 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -1,8 +1,13 @@ from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError -from linode_api4.objects.base import Base, Property +from linode_api4.objects.base import ( + Base, + Property, + _flatten_request_body_recursive, +) from linode_api4.objects.linode import Instance, Region from linode_api4.objects.region import Region +from linode_api4.util import drop_null_keys class VolumeType(Base): @@ -63,21 +68,16 @@ def attach(self, to_linode, config=None): If not given, the last booted Config will be chosen. :type config: Union[Config, int] """ + + body = { + "linode_id": to_linode, + "config": config, + } + result = self._client.post( "{}/attach".format(Volume.api_endpoint), model=self, - data={ - "linode_id": ( - to_linode.id - if issubclass(type(to_linode), Base) - else to_linode - ), - "config": ( - None - if not config - else config.id if issubclass(type(config), Base) else config - ), - }, + data=_flatten_request_body_recursive(drop_null_keys(body)), ) if not "id" in result: From cb39624d52a167024746b34199ec4169ed862ae0 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:58:11 -0500 Subject: [PATCH 262/379] Multiple workflows changes (#483) * Multiple workflows changes * Change build-mode to none * Upgrade AWS collection to v9.1.0 --- .github/dependabot.yml | 7 ++- .github/workflows/ci.yml | 43 +++++++++++++++++ .github/workflows/codeql.yml | 46 +++++++++++++++++++ .github/workflows/dependency-review.yml | 18 ++++++++ .github/workflows/lint.yml | 24 ---------- .github/workflows/main.yml | 26 ----------- .github/workflows/release-cross-repo-test.yml | 2 +- 7 files changed, 114 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/main.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ba1c6b80a..226428122 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,9 @@ updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: - interval: "daily" + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..1fd2ad747 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ + +name: Continuous Integration + +on: + push: + branches: + - dev + - main + pull_request: + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: checkout repo + uses: actions/checkout@v4 + + - name: setup python 3 + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: install dependencies + run: make dev-install + + - name: run linter + run: make lint + + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + run: | + pip install ".[test]" + tox diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..e1826bae2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: "CodeQL Advanced" + +on: + push: + branches: [ "dev", "main", "proj/*" ] + pull_request: + branches: [ "dev", "main", "proj/*" ] + schedule: + - cron: '39 0 * * 6' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: python + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..f2b7117d8 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,18 @@ +name: 'Dependency review' +on: + pull_request: + branches: [ "dev", "main", "proj/*" ] +permissions: + contents: read + pull-requests: write + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 + with: + comment-summary-in-pr: on-failure diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 9f9391533..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Linting Actions -on: - pull_request: null - push: - branches: - - master - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - name: checkout repo - uses: actions/checkout@v4 - - - name: setup python 3 - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - - name: install dependencies - run: make dev-install - - - name: run linter - run: make lint \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index f29a4529f..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,26 +0,0 @@ - -name: Test Suite - -on: - push: - branches: - - dev - - main - pull_request: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.9','3.10','3.11', '3.12'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Run tests - run: | - pip install ".[test]" - tox diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 8708c3422..e99f356e5 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -43,7 +43,7 @@ jobs: pip install -r requirements.txt -r requirements-dev.txt --upgrade-strategy only-if-needed - name: install ansible dependencies - run: ansible-galaxy collection install amazon.aws:==6.0.1 + run: ansible-galaxy collection install amazon.aws:==9.1.0 - name: install collection run: | From a9cf21c73ce9fbeb16ce4c2706433a17ea7633bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 19:03:53 -0500 Subject: [PATCH 263/379] build(deps): bump pypa/gh-action-pypi-publish from 1.8.11 to 1.12.3 (#486) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.11 to 1.12.3. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf...67339c736fd9354cd4f8cb0b744f2b82a74b5c70) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-pypi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index bca202209..e0bf9e1db 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -24,6 +24,6 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@2f6f737ca5f74c637829c0f5c3acd0e29ea5e8bf # pin@release/v1.8.11 + uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # pin@release/v1.12.3 with: password: ${{ secrets.PYPI_API_TOKEN }} From f90352ebbffe724d81e9bc9182b3103a8871e0b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 19:04:16 -0500 Subject: [PATCH 264/379] build(deps): bump crazy-max/ghaction-github-labeler from 5.0.0 to 5.1.0 (#487) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.0.0 to 5.1.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/de749cf181958193cb7debf1a9c5bb28922f3e1b...b54af0c25861143e7c8813d7cbbf46d2c341680c) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index da42b7e4a..444c69ffd 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@de749cf181958193cb7debf1a9c5bb28922f3e1b + uses: crazy-max/ghaction-github-labeler@b54af0c25861143e7c8813d7cbbf46d2c341680c with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml From e69f7c7f76e9f0ad2b0a7ac568289a4e40d9a8b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 19:04:39 -0500 Subject: [PATCH 265/379] build(deps): bump actions/github-script from 6 to 7 (#490) Bumps [actions/github-script](https://github.com/actions/github-script) from 6 to 7. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/github-script dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index b90ee1796..5e81a7829 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -99,7 +99,7 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - - uses: actions/github-script@v6 + - uses: actions/github-script@v7 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: From 087502b011ae2788c8149fb3bb942b38389d3b57 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 19:04:54 -0500 Subject: [PATCH 266/379] build(deps): bump actions/setup-python from 4 to 5 (#489) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-cross-repo-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index e99f356e5..052eaffb4 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -25,7 +25,7 @@ jobs: run: sudo apt-get install -y build-essential - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' From 58a9548dbc336d158b4bf1acac0fefe8d9391db8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 14:16:24 -0500 Subject: [PATCH 267/379] build(deps): bump slackapi/slack-github-action from 1.27.0 to 2.0.0 (#488) Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 1.27.0 to 2.0.0. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Commits](https://github.com/slackapi/slack-github-action/compare/v1.27.0...v2.0.0) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/release-notify-slack.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e02b708e1..886d0a253 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -163,7 +163,7 @@ jobs: steps: - name: Notify Slack - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v2.0.0 with: channel-id: ${{ secrets.SLACK_CHANNEL_ID }} payload: | diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index c0b7b87c1..9432ac416 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -45,7 +45,7 @@ jobs: - name: Notify Slack if: always() && github.repository == 'linode/linode_api4-python' - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v2.0.0 with: channel-id: ${{ secrets.SLACK_CHANNEL_ID }} payload: | diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml index aa3d30af4..bf941e7bf 100644 --- a/.github/workflows/release-notify-slack.yml +++ b/.github/workflows/release-notify-slack.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Notify Slack - Main Message id: main_message - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v2.0.0 with: channel-id: ${{ secrets.DEV_DX_SLACK_CHANNEL_ID }} payload: | From 726d607525590f93f8a40df8018c325e0e840a0c Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 8 Jan 2025 11:02:07 -0500 Subject: [PATCH 268/379] Remove unnecessary permissions for codeql scan workflow (#491) --- .github/workflows/codeql.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e1826bae2..7168ea488 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,16 +13,8 @@ jobs: name: Analyze (${{ matrix.language }}) runs-on: ubuntu-latest permissions: - # required for all workflows security-events: write - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - strategy: fail-fast: false matrix: From d8bc120aaa3211ed3065c5de5176bc22cee73e7d Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Mon, 13 Jan 2025 09:09:47 -0500 Subject: [PATCH 269/379] Implemented changes for VPU (#485) * Implemented changes for VPU * Fix lint * Updated debian version --- linode_api4/objects/linode.py | 1 + test/fixtures/linode_types.json | 33 ++++++++++++- test/integration/models/linode/test_linode.py | 47 ++++++++++++++++--- test/unit/objects/linode_test.py | 3 +- 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 8fe71bb7d..776e1f988 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -269,6 +269,7 @@ class Type(Base): "vcpus": Property(), "gpus": Property(), "successor": Property(), + "accelerated_devices": Property(), # type_class is populated from the 'class' attribute of the returned JSON } diff --git a/test/fixtures/linode_types.json b/test/fixtures/linode_types.json index 819867b79..dee3209ee 100644 --- a/test/fixtures/linode_types.json +++ b/test/fixtures/linode_types.json @@ -1,9 +1,10 @@ { - "results": 4, + "results": 5, "pages": 1, "page": 1, "data": [ { + "accelerated_devices": 0, "disk": 20480, "memory": 1024, "transfer": 1000, @@ -52,6 +53,7 @@ "successor": null }, { + "accelerated_devices": 0, "disk": 20480, "memory": 16384, "transfer": 5000, @@ -100,6 +102,7 @@ "successor": null }, { + "accelerated_devices": 0, "disk": 30720, "memory": 2048, "transfer": 2000, @@ -148,6 +151,7 @@ "successor": null }, { + "accelerated_devices": 0, "disk": 49152, "memory": 4096, "transfer": 3000, @@ -194,6 +198,33 @@ } ], "successor": null + }, + { + "id": "g1-accelerated-netint-vpu-t1u1-m", + "label": "Netint Quadra T1U x1 Medium", + "price": { + "hourly": 0.0, + "monthly": 0.0 + }, + "region_prices": [], + "addons": { + "backups": { + "price": { + "hourly": 0.0, + "monthly": 0.0 + }, + "region_prices": [] + } + }, + "memory": 24576, + "disk": 307200, + "transfer": 0, + "vcpus": 12, + "gpus": 0, + "network_out": 16000, + "class": "accelerated", + "successor": null, + "accelerated_devices": 1 } ] } \ No newline at end of file diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index a8ba2b21e..2ec1fed63 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -38,7 +38,7 @@ def linode_with_volume_firewall(test_linode_client): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", label=label + "_modlinode", ) @@ -76,7 +76,27 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="session") +def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = "us-lax" + + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g1-accelerated-netint-vpu-t1u1-s", + region, + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) @@ -147,7 +167,7 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", label=label + "_long_tests", firewall=e2e_test_firewall, ) @@ -196,6 +216,13 @@ def test_get_linode(test_linode_client, linode_with_volume_firewall): assert linode.id == linode_with_volume_firewall.id +def test_get_vpu(test_linode_client, linode_for_vpu_tests): + linode = test_linode_client.load(Instance, linode_for_vpu_tests.id) + + assert linode.label == linode_for_vpu_tests.label + assert hasattr(linode.specs, "accelerated_devices") + + def test_linode_transfer(test_linode_client, linode_with_volume_firewall): linode = test_linode_client.load(Instance, linode_with_volume_firewall.id) @@ -214,7 +241,7 @@ def test_linode_rebuild(test_linode_client): label = get_test_label() + "_rebuild" linode, password = client.linode.instance_create( - "g6-nanode-1", region, image="linode/debian10", label=label + "g6-nanode-1", region, image="linode/debian12", label=label ) wait_for_condition(10, 100, get_status, linode, "running") @@ -222,14 +249,14 @@ def test_linode_rebuild(test_linode_client): retry_sending_request( 3, linode.rebuild, - "linode/debian10", + "linode/debian12", disk_encryption=InstanceDiskEncryptionType.disabled, ) wait_for_condition(10, 100, get_status, linode, "rebuilding") assert linode.status == "rebuilding" - assert linode.image.id == "linode/debian10" + assert linode.image.id == "linode/debian12" assert linode.disk_encryption == InstanceDiskEncryptionType.disabled @@ -272,7 +299,7 @@ def test_delete_linode(test_linode_client): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", label=label + "_linode", ) @@ -591,6 +618,9 @@ def test_get_linode_types(test_linode_client): assert len(types) > 0 assert "g6-nanode-1" in ids + for linode_type in types: + assert hasattr(linode_type, "accelerated_devices") + def test_get_linode_types_overrides(test_linode_client): types = test_linode_client.linode.types() @@ -691,6 +721,9 @@ def test_create_vlan(self, linode_for_network_interface_tests): assert interface.label == "testvlan" assert interface.ipam_address == "10.0.0.2/32" + def test_create_vpu(self, test_linode_client, linode_for_vpu_tests): + assert hasattr(linode_for_vpu_tests.specs, "accelerated_devices") + def test_create_vpc( self, test_linode_client, diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 700e5d0db..5f95e8d3c 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -656,7 +656,7 @@ def test_get_types(self): """ types = self.client.linode.types() - self.assertEqual(len(types), 4) + self.assertEqual(len(types), 5) for t in types: self.assertTrue(t._populated) self.assertIsNotNone(t.id) @@ -667,6 +667,7 @@ def test_get_types(self): self.assertIsNone(t.successor) self.assertIsNotNone(t.region_prices) self.assertIsNotNone(t.addons.backups.region_prices) + self.assertIsNotNone(t.accelerated_devices) def test_get_type_by_id(self): """ From f1fa70eecc9d92496c14f58d771a006be02143dd Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 13 Jan 2025 12:36:26 -0800 Subject: [PATCH 270/379] test: update deprecated images and slack payload in workflows (#495) * update deprecated images * fix slack payload for v2.0.0 * fix slack payload for v2.0.0 * update test image --- .github/workflows/e2e-test.yml | 82 ++++++------------- .github/workflows/nightly-smoke-tests.yml | 81 ++++++------------ .github/workflows/release-notify-slack.yml | 22 ++--- test/integration/conftest.py | 2 +- .../linode_client/test_linode_client.py | 6 +- .../models/account/test_account.py | 2 +- .../models/firewall/test_firewall.py | 2 +- test/integration/models/linode/test_linode.py | 2 +- .../models/nodebalancer/test_nodebalancer.py | 2 +- test/integration/models/volume/test_volume.py | 2 +- test/unit/objects/linode_test.py | 4 +- 11 files changed, 71 insertions(+), 136 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 886d0a253..51eda09bf 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -160,64 +160,34 @@ jobs: runs-on: ubuntu-latest needs: [integration-tests] if: ${{ (success() || failure()) && github.repository == 'linode/linode_api4-python' }} # Run even if integration tests fail and only on main repository - steps: - name: Notify Slack uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Build Result:*\n${{ needs.integration-tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" - }, - { - "type": "mrkdwn", - "text": "*Branch:*\n`${{ github.ref_name }}`" - } - ] - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" - }, - { - "type": "mrkdwn", - "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" - } - ] - }, - { - "type": "divider" - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" - } - ] - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ needs.integration-tests.result == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" \ No newline at end of file diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 9432ac416..244df9afd 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -47,60 +47,31 @@ jobs: if: always() && github.repository == 'linode/linode_api4-python' uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" - } - }, - { - "type": "divider" - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" - }, - { - "type": "mrkdwn", - "text": "*Branch:*\n`${{ github.ref_name }}`" - } - ] - }, - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" - }, - { - "type": "mrkdwn", - "text": "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" - } - ] - }, - { - "type": "divider" - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" - } - ] - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + channel: ${{ secrets.SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + - type: divider + - type: section + fields: + - type: mrkdwn + text: "*Build Result:*\n${{ steps.smoke_tests.outcome == 'success' && ':large_green_circle: Build Passed' || ':red_circle: Build Failed' }}" + - type: mrkdwn + text: "*Branch:*\n`${{ github.ref_name }}`" + - type: section + fields: + - type: mrkdwn + text: "*Commit Hash:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>" + - type: mrkdwn + text: "*Run URL:*\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run Details>" + - type: divider + - type: context + elements: + - type: mrkdwn + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml index bf941e7bf..ea1a4da68 100644 --- a/.github/workflows/release-notify-slack.yml +++ b/.github/workflows/release-notify-slack.yml @@ -13,18 +13,12 @@ jobs: id: main_message uses: slackapi/slack-github-action@v2.0.0 with: - channel-id: ${{ secrets.DEV_DX_SLACK_CHANNEL_ID }} + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} payload: | - { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*New Release Published: _linode_api4-python_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" - } - } - ] - } - env: - SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file + channel: ${{ secrets.DEV_DX_SLACK_CHANNEL_ID }} + blocks: + - type: section + text: + type: mrkdwn + text: "*New Release Published: _linode_api4-python_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index ba4a2ee14..34aba39db 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -184,7 +184,7 @@ def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 2802c90f9..eb1b06369 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -19,7 +19,7 @@ def setup_client_and_linode(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) @@ -250,7 +250,7 @@ def test_create_linode_instance_without_image(test_linode_client): def test_create_linode_instance_with_image(setup_client_and_linode): linode = setup_client_and_linode[1] - assert re.search("linode/debian10", str(linode.image)) + assert re.search("linode/debian12", str(linode.image)) def test_create_linode_with_interfaces(test_linode_client): @@ -262,7 +262,7 @@ def test_create_linode_with_interfaces(test_linode_client): "g6-nanode-1", region, label=label, - image="linode/debian10", + image="linode/debian12", interfaces=[ {"purpose": "public"}, ConfigInterface( diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 8bdc8c60e..decad434f 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -71,7 +71,7 @@ def test_latest_get_event(test_linode_client, e2e_test_firewall): linode, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/debian10", + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) diff --git a/test/integration/models/firewall/test_firewall.py b/test/integration/models/firewall/test_firewall.py index 6a9f6f079..16805f3b8 100644 --- a/test/integration/models/firewall/test_firewall.py +++ b/test/integration/models/firewall/test_firewall.py @@ -14,7 +14,7 @@ def linode_fw(test_linode_client): label = get_test_label() linode_instance, password = client.linode.instance_create( - "g6-nanode-1", region, image="linode/debian10", label=label + "g6-nanode-1", region, image="linode/debian12", label=label ) yield linode_instance diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 2ec1fed63..6879df1d3 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -189,7 +189,7 @@ def linode_with_disk_encryption(test_linode_client, request): linode_instance, password = client.linode.instance_create( "g6-nanode-1", target_region, - image="linode/ubuntu23.10", + image="linode/ubuntu24.10", label=label, booted=False, disk_encryption=disk_encryption, diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index d8a8a53b1..21f4d0322 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -36,7 +36,7 @@ def linode_with_private_ip(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", TEST_REGION, - image="linode/debian10", + image="linode/debian12", label=label, private_ip=True, firewall=e2e_test_firewall, diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 6588d92a7..56395d203 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -49,7 +49,7 @@ def linode_for_volume(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", TEST_REGION, - image="linode/debian10", + image="linode/debian12", label=label, firewall=e2e_test_firewall, ) diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 5f95e8d3c..a911152f6 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -413,7 +413,7 @@ def test_create_disk(self): 1234, label="test", authorized_users=["test"], - image="linode/debian10", + image="linode/debian12", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") self.assertEqual( @@ -422,7 +422,7 @@ def test_create_disk(self): "size": 1234, "label": "test", "root_pass": gen_pass, - "image": "linode/debian10", + "image": "linode/debian12", "authorized_users": ["test"], "read_only": False, }, From 8e5134e8865bec0e4478d1dcdd39b7f88e80461d Mon Sep 17 00:00:00 2001 From: John Callahan <114753608+jcallahan-akamai@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:52:13 -0500 Subject: [PATCH 271/379] TPT-3319: Add support for Apply Linode Firewalls (#492) * Add support for linode/instances/:id/firewalls/apply * updating the test image --------- Co-authored-by: ykim-1 Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> --- linode_api4/objects/linode.py | 16 ++++++++++++++++ test/integration/models/linode/test_linode.py | 8 ++++++++ test/unit/objects/linode_test.py | 13 +++++++++++++ 3 files changed, 37 insertions(+) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 776e1f988..7a8c959a1 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1639,6 +1639,22 @@ def firewalls(self): for firewall in result["data"] ] + def apply_firewalls(self): + """ + Reapply assigned firewalls to a Linode in case they were not applied successfully. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-apply-firewalls + + :returns: Returns True if the operation was successful + :rtype: bool + """ + + self._client.post( + "{}/firewalls/apply".format(Instance.api_endpoint), model=self + ) + + return True + def nodebalancers(self): """ View a list of NodeBalancers that are assigned to this Linode and readable by the requesting User. diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 6879df1d3..d97a8294a 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -453,6 +453,14 @@ def test_linode_firewalls(linode_with_volume_firewall): assert "firewall" in firewalls[0].label +def test_linode_apply_firewalls(linode_with_volume_firewall): + linode = linode_with_volume_firewall + + result = linode.apply_firewalls() + + assert result + + def test_linode_volumes(linode_with_volume_firewall): linode = linode_with_volume_firewall diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index a911152f6..44fea4f36 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -284,6 +284,19 @@ def test_firewalls(self): self.assertEqual(m.call_url, "/linode/instances/123/firewalls") self.assertEqual(len(result), 1) + def test_apply_firewalls(self): + """ + Tests that you can submit a correct apply firewalls api request + """ + linode = Instance(self.client, 123) + + with self.mock_post({}) as m: + result = linode.apply_firewalls() + self.assertEqual( + m.call_url, "/linode/instances/123/firewalls/apply" + ) + self.assertEqual(result, True) + def test_volumes(self): """ Tests that you can submit a correct volumes api request From e2e9343c99ba91a74ed53af168bb8427ae631e88 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 23 Jan 2025 10:33:47 -0500 Subject: [PATCH 272/379] delete ip (#497) --- linode_api4/objects/networking.py | 14 ++++++++++++++ .../models/networking/test_networking.py | 13 +++++++++++++ test/unit/objects/networking_test.py | 13 ++++++++++++- 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 613eca21c..25130a919 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -112,6 +112,20 @@ def to(self, linode): return {"address": self.address, "linode_id": linode.id} + def delete(self): + """ + Override the delete() function from Base to use the correct endpoint. + """ + resp = self._client.delete( + "/linode/instances/{}/ips/{}".format(self.linode_id, self.address), + model=self, + ) + + if "error" in resp: + return False + self.invalidate() + return True + @dataclass class VPCIPAddress(JSONObject): diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 430bd94b9..9bffb57e8 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -150,3 +150,16 @@ def test_network_transfer_prices(test_linode_client): transfer_prices[0].price is None or transfer_prices[0].price.hourly >= 0 ) + + +def test_allocate_and_delete_ip(test_linode_client, create_linode): + linode = create_linode + ip = test_linode_client.networking.ip_allocate(linode.id) + linode.invalidate() + + assert ip.linode_id == linode.id + assert ip.address in linode.ipv4 + + is_deleted = ip.delete() + + assert is_deleted is True diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index dabf1ee2b..7192683ef 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -1,6 +1,6 @@ from test.unit.base import ClientBaseCase -from linode_api4 import ExplicitNullValue +from linode_api4 import ExplicitNullValue, Instance from linode_api4.objects import Firewall, IPAddress, IPv6Range @@ -83,3 +83,14 @@ def test_vpc_nat_1_1(self): self.assertEqual(ip.vpc_nat_1_1.vpc_id, 242) self.assertEqual(ip.vpc_nat_1_1.subnet_id, 194) self.assertEqual(ip.vpc_nat_1_1.address, "139.144.244.36") + + def test_delete_ip(self): + """ + Tests that deleting an IP creates the correct api request + """ + with self.mock_delete() as m: + ip = IPAddress(self.client, "127.0.0.1") + ip.to(Instance(self.client, 123)) + ip.delete() + + self.assertEqual(m.call_url, "/linode/instances/123/ips/127.0.0.1") From 0e1f0a3bb1eaadcda833723355734b611b7a4ff3 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:37:31 -0800 Subject: [PATCH 273/379] test/workflow: Update make test commands and related workflows (#498) * gha test slack * gha test slack 1 * refactor make test commands and update related workflows --- .github/workflows/e2e-test-pr.yml | 2 +- .github/workflows/e2e-test.yml | 18 +++++----- .github/workflows/nightly-smoke-tests.yml | 2 +- Makefile | 41 ++++++++--------------- README.rst | 10 +++--- e2e_scripts | 2 +- 6 files changed, 31 insertions(+), 44 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 5e81a7829..2e9908433 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -80,7 +80,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make testint TEST_ARGS="--junitxml=${report_filename}" TEST_SUITE="${{ github.event.inputs.test_suite }}" + make test-int 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 51eda09bf..4a2360d50 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -4,18 +4,18 @@ on: workflow_dispatch: inputs: use_minimal_test_account: - description: 'Use minimal test account' + description: 'Indicate whether to use a minimal test account with limited resources for testing. Defaults to "false"' required: false default: 'false' sha: - description: 'The hash value of the commit' - required: false + description: 'Specify commit hash to test. This value is mandatory to ensure the tests run against a specific commit' + required: true default: '' python-version: - description: 'Specify Python version to use' + description: 'Specify the Python version to use for running tests. Leave empty to use the default Python version configured in the environment' required: false run-eol-python-version: - description: 'Run EOL python version?' + description: 'Indicates whether to run tests using an End-of-Life (EOL) Python version. Defaults to "false". Choose "true" to include tests for deprecated Python versions' required: false default: 'false' type: choice @@ -28,8 +28,8 @@ on: - dev env: - DEFAULT_PYTHON_VERSION: "3.9" - EOL_PYTHON_VERSION: "3.8" + DEFAULT_PYTHON_VERSION: "3.10" + EOL_PYTHON_VERSION: "3.9" EXIT_STATUS: 0 jobs: @@ -72,7 +72,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make testint TEST_ARGS="--junitxml=${report_filename}" + make test-int TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} @@ -159,7 +159,7 @@ jobs: notify-slack: runs-on: ubuntu-latest needs: [integration-tests] - if: ${{ (success() || failure()) && github.repository == 'linode/linode_api4-python' }} # Run even if integration tests fail and only on main repository + if: ${{ (success() || failure()) }} # Run even if integration tests fail and only on main repository steps: - name: Notify Slack uses: slackapi/slack-github-action@v2.0.0 diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 244df9afd..fc48ee010 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -39,7 +39,7 @@ jobs: - name: Run smoke tests id: smoke_tests run: | - make smoketest + make test-smoke env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} diff --git a/Makefile b/Makefile index 03a527169..4bfb1c348 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,9 @@ PYTHON ?= python3 -TEST_CASE_COMMAND := -TEST_SUITE := -TEST_ARGS := - LINODE_SDK_VERSION ?= "0.0.0.dev" VERSION_MODULE_DOCSTRING ?= \"\"\"\nThe version of this linode_api4 package.\n\"\"\"\n\n VERSION_FILE := ./linode_api4/version.py -ifdef TEST_CASE - TEST_CASE_COMMAND = -k $(TEST_CASE) -endif - -ifdef TEST_SUITE - ifneq ($(TEST_SUITE),linode_client) - ifneq ($(TEST_SUITE),login_client) - TEST_COMMAND = models/$(TEST_SUITE) - else - TEST_COMMAND = login_client - endif - else - TEST_COMMAND = linode_client - endif -endif - .PHONY: clean clean: mkdir -p dist @@ -73,14 +53,21 @@ lint: build $(PYTHON) -m pylint linode_api4 $(PYTHON) -m twine check dist/* -.PHONY: testint -testint: - $(PYTHON) -m pytest test/integration/${TEST_COMMAND} ${TEST_CASE_COMMAND} ${TEST_ARGS} +# Integration Test Arguments +# TEST_SUITE: Optional, specify a test suite (e.g. domain), Default to run everything if not set +# TEST_CASE: Optional, specify a test case (e.g. 'test_image_replication') +# TEST_ARGS: Optional, additional arguments for pytest (e.g. '-v' for verbose mode) + +TEST_COMMAND = $(if $(TEST_SUITE),$(if $(filter $(TEST_SUITE),linode_client login_client),$(TEST_SUITE),models/$(TEST_SUITE))) + +.PHONY: test-int +test-int: + $(PYTHON) -m pytest test/integration/${TEST_COMMAND} $(if $(TEST_CASE),-k $(TEST_CASE)) ${TEST_ARGS} -.PHONY: testunit -testunit: +.PHONY: test-unit +test-unit: $(PYTHON) -m pytest test/unit -.PHONY: smoketest -smoketest: +.PHONY: test-smoke +test-smoke: $(PYTHON) -m pytest -m smoke test/integration \ No newline at end of file diff --git a/README.rst b/README.rst index 1e6b310f4..5615bb488 100644 --- a/README.rst +++ b/README.rst @@ -148,16 +148,16 @@ Running the tests ^^^^^^^^^^^^^^^^^ Run the tests locally using the make command. Run the entire test suite using command below:: - make testint + make test-int To run a specific package/suite, use the environment variable `TEST_SUITE` using directory names in `integration/...` folder :: - make TEST_SUITE="account" testint // Runs tests in `integration/models/account` directory - make TEST_SUITE="linode_client" testint // Runs tests in `integration/linode_client` directory + make TEST_SUITE="account" test-int // Runs tests in `integration/models/account` directory + make TEST_SUITE="linode_client" test-int // Runs tests in `integration/linode_client` directory -Lastly to run a specific test case use environment variable `TEST_CASE` with `testint` command:: +Lastly to run a specific test case use environment variable `TEST_CASE` with `test-int` command:: - make TEST_CASE=test_get_domain_record testint + make TEST_CASE=test_get_domain_record test-int Documentation ------------- diff --git a/e2e_scripts b/e2e_scripts index 6b71cb72e..0f2ff0169 160000 --- a/e2e_scripts +++ b/e2e_scripts @@ -1 +1 @@ -Subproject commit 6b71cb72eb20a18ace82f9e73a0f99fe1141d625 +Subproject commit 0f2ff016956090c6fff046f4479e7efe8d0086e5 From 9488e73817da53a83781fc9e2e0dd256920c6347 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:17:00 -0800 Subject: [PATCH 274/379] test: Roll back boto3 for report upload (#502) * gha test 1 * roll back boto3 version for test upload * roll back boto3 version for test upload * address codeQL warnings * address codeQL warnings * make format --- .github/workflows/e2e-test.yml | 64 ++++++++++++++++++++++++------ test/unit/objects/database_test.py | 9 ++++- test/unit/objects/vpc_test.py | 2 +- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 4a2360d50..142b2ff84 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -76,20 +76,14 @@ jobs: env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Upload test results + - name: Upload Test Report as Artifact if: always() - run: | - filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ - --branch_name "${GITHUB_REF#refs/*/}" \ - --gha_run_id "$GITHUB_RUN_ID" \ - --gha_run_number "$GITHUB_RUN_NUMBER" \ - --xmlfile "${filename}" - sync - python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" - env: - LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} - LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + uses: actions/upload-artifact@v4 + with: + name: test-report-file + if-no-files-found: ignore + path: '*.xml' + retention-days: 1 apply-calico-rules: runs-on: ubuntu-latest @@ -156,6 +150,50 @@ jobs: env: LINODE_CLI_TOKEN: ${{ env.LINODE_TOKEN }} + process-upload-report: + runs-on: ubuntu-latest + needs: [integration-tests] + if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Download test report + uses: actions/download-artifact@v4 + with: + name: test-report-file + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python dependencies + run: pip3 install requests wheel boto3==1.35.99 + + - name: Set release version env + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + + + - name: Add variables and upload test results + if: always() + run: | + filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ + --branch_name "${GITHUB_REF#refs/*/}" \ + --gha_run_id "$GITHUB_RUN_ID" \ + --gha_run_number "$GITHUB_RUN_NUMBER" \ + --xmlfile "${filename}" + sync + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" + env: + LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} + LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + notify-slack: runs-on: ubuntu-latest needs: [integration-tests] diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index d5b84cebb..26250b7b2 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -1,3 +1,4 @@ +import logging from test.unit.base import ClientBaseCase from linode_api4 import PostgreSQLDatabase @@ -106,6 +107,8 @@ def test_create(self): Test that MySQL databases can be created """ + logger = logging.getLogger(__name__) + with self.mock_post("/databases/mysql/instances") as m: # We don't care about errors here; we just want to # validate the request. @@ -117,8 +120,10 @@ def test_create(self): "g6-standard-1", cluster_size=3, ) - except Exception: - pass + except Exception as e: + logger.warning( + "An error occurred while validating the request: %s", e + ) self.assertEqual(m.method, "post") self.assertEqual(m.call_url, "/databases/mysql/instances") diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 4d80716d4..20d36139b 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -138,7 +138,7 @@ def test_list_ips(self): ip = result[0] assert ip.address == "10.0.0.2" - assert ip.address_range == None + assert ip.address_range is None assert ip.vpc_id == 123 assert ip.subnet_id == 456 assert ip.region == "us-mia" From 7c0aa01eb805de513877f743b2de4ebf6f1b8e72 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:34:29 -0500 Subject: [PATCH 275/379] Implement fields related to LKE APL (#496) * Add APL-related fields * Add unit test * Add integration test * Fix integration test * Add beta notice * Fix note * Make apl_enabled immutable * make format --- linode_api4/groups/lke.py | 13 +++++-- linode_api4/objects/linode.py | 2 +- linode_api4/objects/lke.py | 31 ++++++++++++++++ test/fixtures/lke_clusters.json | 3 +- test/fixtures/lke_clusters_18881.json | 3 +- test/integration/models/image/test_image.py | 4 +-- test/integration/models/lke/test_lke.py | 39 +++++++++++++++++++++ test/unit/objects/image_test.py | 4 +-- test/unit/objects/lke_test.py | 35 ++++++++++++++++++ 9 files changed, 125 insertions(+), 9 deletions(-) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index d0de66f37..d64d45536 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -66,6 +66,7 @@ def cluster_create( control_plane: Union[ LKEClusterControlPlaneOptions, Dict[str, Any] ] = None, + apl_enabled: bool = False, **kwargs, ): """ @@ -100,8 +101,12 @@ def cluster_create( formatted dicts. :param kube_version: The version of Kubernetes to use :type kube_version: KubeVersion or str - :param control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest - :type control_plane: The control plane configuration of this LKE cluster. + :param control_plane: The control plane configuration of this LKE cluster. + :type control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest + :param apl_enabled: Whether this cluster should use APL. + NOTE: This endpoint is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type apl_enabled: bool :param kwargs: Any other arguments to pass along to the API. See the API docs for possible values. @@ -120,6 +125,10 @@ def cluster_create( } params.update(kwargs) + # Prevent errors for users without access to APL + if apl_enabled: + params["apl_enabled"] = apl_enabled + result = self.client.post( "/lke/clusters", data=_flatten_request_body_recursive(drop_null_keys(params)), diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 7a8c959a1..46af5d970 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1936,7 +1936,7 @@ def _serialize(self): def _expand_placement_group_assignment( pg: Union[ InstancePlacementGroupAssignment, "PlacementGroup", Dict[str, Any], int - ] + ], ) -> Optional[Dict[str, Any]]: """ Expands the placement group argument into a dict for use in an API request body. diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 7ff6b0fd8..e675eae8e 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -257,6 +257,7 @@ class LKECluster(Base): "k8s_version": Property(slug_relationship=KubeVersion, mutable=True), "pools": Property(derived_class=LKENodePool), "control_plane": Property(mutable=True), + "apl_enabled": Property(), } def invalidate(self): @@ -353,6 +354,36 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: return LKEClusterControlPlaneACL.from_json(self._control_plane_acl) + @property + def apl_console_url(self) -> Optional[str]: + """ + Returns the URL of this cluster's APL installation if this cluster + is APL-enabled, else None. + + :returns: The URL of the APL console for this cluster. + :rtype: str or None + """ + + if not self.apl_enabled: + return None + + return f"https://console.lke{self.id}.akamai-apl.net" + + @property + def apl_health_check_url(self) -> Optional[str]: + """ + Returns the URL of this cluster's APL health check endpoint if this cluster + is APL-enabled, else None. + + :returns: The URL of the APL console for this cluster. + :rtype: str or None + """ + + if not self.apl_enabled: + return None + + return f"https://auth.lke{self.id}.akamai-apl.net/ready" + def node_pool_create( self, node_type: Union[Type, str], diff --git a/test/fixtures/lke_clusters.json b/test/fixtures/lke_clusters.json index 787a2fae5..1a932c8ec 100644 --- a/test/fixtures/lke_clusters.json +++ b/test/fixtures/lke_clusters.json @@ -6,5 +6,6 @@ "label": "example-cluster", "region": "ap-west", "k8s_version": "1.19", - "tags": [] + "tags": [], + "apl_enabled": true } diff --git a/test/fixtures/lke_clusters_18881.json b/test/fixtures/lke_clusters_18881.json index 755d11c58..bb5807c18 100644 --- a/test/fixtures/lke_clusters_18881.json +++ b/test/fixtures/lke_clusters_18881.json @@ -9,5 +9,6 @@ "tags": [], "control_plane": { "high_availability": true - } + }, + "apl_enabled": true } \ No newline at end of file diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 94c819709..9124ddf97 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -32,8 +32,8 @@ def image_upload_url(test_linode_client): @pytest.fixture(scope="session") def test_uploaded_image(test_linode_client): test_image_content = ( - b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" - b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x1f\x8b\x08\x08\xbd\x5c\x91\x60\x00\x03\x74\x65\x73\x74\x2e\x69" + b"\x6d\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) label = get_test_label() + "_image" diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 4a3ba6c7e..f2fb3f2e5 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -110,6 +110,32 @@ def lke_cluster_with_labels_and_taints(test_linode_client): cluster.delete() +@pytest.fixture(scope="session") +def lke_cluster_with_apl(test_linode_client): + version = test_linode_client.lke.versions()[0] + + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) + + # NOTE: g6-dedicated-4 is the minimum APL-compatible Linode type + node_pools = test_linode_client.lke.node_pool("g6-dedicated-4", 3) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + node_pools, + version, + control_plane=LKEClusterControlPlaneOptions( + high_availability=True, + ), + apl_enabled=True, + ) + + yield cluster + + cluster.delete() + + def get_cluster_status(cluster: LKECluster, status: str): return cluster._raw_json["status"] == status @@ -328,6 +354,19 @@ def test_lke_cluster_labels_and_taints(lke_cluster_with_labels_and_taints): assert LKENodePoolTaint.from_json(updated_taints[1]) in pool.taints +@pytest.mark.flaky(reruns=3, reruns_delay=2) +def test_lke_cluster_with_apl(lke_cluster_with_apl): + assert lke_cluster_with_apl.apl_enabled == True + assert ( + lke_cluster_with_apl.apl_console_url + == f"https://console.lke{lke_cluster_with_apl.id}.akamai-apl.net" + ) + assert ( + lke_cluster_with_apl.apl_health_check_url + == f"https://auth.lke{lke_cluster_with_apl.id}.akamai-apl.net/ready" + ) + + def test_lke_types(test_linode_client): types = test_linode_client.lke.types() diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index 5d1ce42d5..f479d021f 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -8,8 +8,8 @@ # A minimal gzipped image that will be accepted by the API TEST_IMAGE_CONTENT = ( - b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" - b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x1f\x8b\x08\x08\xbd\x5c\x91\x60\x00\x03\x74\x65\x73\x74\x2e\x69" + b"\x6d\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 100f36487..c394e2f9a 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -39,6 +39,7 @@ def test_get_cluster(self): self.assertEqual(cluster.region.id, "ap-west") self.assertEqual(cluster.k8s_version.id, "1.19") self.assertTrue(cluster.control_plane.high_availability) + self.assertTrue(cluster.apl_enabled) def test_get_pool(self): """ @@ -352,6 +353,40 @@ def test_cluster_create_with_labels_and_taints(self): ], } + def test_cluster_create_with_apl(self): + """ + Tests that an LKE cluster can be created with APL enabled. + """ + + with self.mock_post("lke/clusters") as m: + cluster = self.client.lke.cluster_create( + "us-mia", + "test-aapl-cluster", + [ + self.client.lke.node_pool( + "g6-dedicated-4", + 3, + ) + ], + "1.29", + apl_enabled=True, + control_plane=LKEClusterControlPlaneOptions( + high_availability=True, + ), + ) + + assert m.call_data["apl_enabled"] == True + assert m.call_data["control_plane"]["high_availability"] == True + + assert ( + cluster.apl_console_url == "https://console.lke18881.akamai-apl.net" + ) + + assert ( + cluster.apl_health_check_url + == "https://auth.lke18881.akamai-apl.net/ready" + ) + def test_populate_with_taints(self): """ Tests that LKENodePool correctly handles a list of LKENodePoolTaint and Dict objects. From 1e45b03a0771cdea772b72de157f1e0338d8d915 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jan 2025 15:23:27 -0500 Subject: [PATCH 276/379] build(deps): bump pypa/gh-action-pypi-publish from 1.12.3 to 1.12.4 (#501) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.12.3 to 1.12.4. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/67339c736fd9354cd4f8cb0b744f2b82a74b5c70...76f52bc884231f62b9a034ebfe128415bbaabdfc) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-pypi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index e0bf9e1db..d5338b7a7 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -24,6 +24,6 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # pin@release/v1.12.3 + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # pin@release/v1.12.4 with: password: ${{ secrets.PYPI_API_TOKEN }} From 9e7b52c1d5ee5c878b036df975882fb075a16a9d Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:13:16 -0500 Subject: [PATCH 277/379] new: Support deleting VLAN in networking group (#500) * delete vlan * fix doc --- linode_api4/groups/networking.py | 27 ++++++++++ test/integration/conftest.py | 19 +++++++ .../models/networking/test_networking.py | 50 ++++++++++++++++++- test/unit/objects/networking_test.py | 16 +++++- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 4820b706d..ba1e656bd 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -367,3 +367,30 @@ def transfer_prices(self, *filters): return self.client._get_and_filter( NetworkTransferPrice, *filters, endpoint="/network-transfer/prices" ) + + def delete_vlan(self, vlan, region): + """ + This operation deletes a VLAN. + You can't delete a VLAN if it's still attached to a Linode. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-vlan + + :param vlan: The label of the VLAN to be deleted. + :type vlan: str or VLAN + :param region: The VLAN's region. + :type region: str or Region + """ + if isinstance(region, Region): + region = region.id + + if isinstance(vlan, VLAN): + vlan = vlan.label + resp = self.client.delete( + "/networking/vlans/{}/{}".format(region, vlan), + model=self, + ) + + if "error" in resp: + return False + + return True diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 34aba39db..8c7d44a57 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -502,3 +502,22 @@ def pytest_configure(config): "markers", "smoke: mark test as part of smoke test suite", ) + + +@pytest.fixture(scope="session") +def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = get_region(client, {"Linodes", "Vlans"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + yield linode_instance + + linode_instance.delete() diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 9bffb57e8..032436246 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -4,11 +4,15 @@ get_region, get_token, ) -from test.integration.helpers import get_test_label +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) import pytest -from linode_api4 import LinodeClient +from linode_api4 import Instance, LinodeClient from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress from linode_api4.objects.networking import NetworkTransferPrice, Price @@ -163,3 +167,45 @@ def test_allocate_and_delete_ip(test_linode_client, create_linode): is_deleted = ip.delete() assert is_deleted is True + + +def get_status(linode: Instance, status: str): + return linode.status == status + + +def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): + linode = linode_for_vlan_tests + + config: Config = linode.configs[0] + + config.interfaces = [] + config.save() + + vlan_label = "testvlan" + interface = config.interface_create_vlan( + label=vlan_label, ipam_address="10.0.0.2/32" + ) + + config.invalidate() + + assert interface.id == config.interfaces[0].id + assert interface.purpose == "vlan" + assert interface.label == vlan_label + + # Remove the VLAN interface and reboot Linode + config.interfaces = [] + config.save() + + wait_for_condition(3, 100, get_status, linode, "running") + + retry_sending_request(3, linode.reboot) + + wait_for_condition(3, 100, get_status, linode, "rebooting") + assert linode.status == "rebooting" + + # Delete the VLAN + is_deleted = test_linode_client.networking.delete_vlan( + vlan_label, linode.region + ) + + assert is_deleted is True diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index 7192683ef..d12167d8c 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -1,6 +1,6 @@ from test.unit.base import ClientBaseCase -from linode_api4 import ExplicitNullValue, Instance +from linode_api4 import VLAN, ExplicitNullValue, Instance, Region from linode_api4.objects import Firewall, IPAddress, IPv6Range @@ -94,3 +94,17 @@ def test_delete_ip(self): ip.delete() self.assertEqual(m.call_url, "/linode/instances/123/ips/127.0.0.1") + + def test_delete_vlan(self): + """ + Tests that deleting a VLAN creates the correct api request + """ + with self.mock_delete() as m: + self.client.networking.delete_vlan( + VLAN(self.client, "vlan-test"), + Region(self.client, "us-southeast"), + ) + + self.assertEqual( + m.call_url, "/networking/vlans/us-southeast/vlan-test" + ) From f2b4732c86bcf7314e42653c1b6a1d76c6079e68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Feb 2025 08:46:31 -0800 Subject: [PATCH 278/379] build(deps): bump crazy-max/ghaction-github-labeler from 5.1.0 to 5.2.0 (#505) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/b54af0c25861143e7c8813d7cbbf46d2c341680c...31674a3852a9074f2086abcf1c53839d466a47e7) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 444c69ffd..8a9bcadd2 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@b54af0c25861143e7c8813d7cbbf46d2c341680c + uses: crazy-max/ghaction-github-labeler@31674a3852a9074f2086abcf1c53839d466a47e7 with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml From d3de87468be1514b27859765fc2e8310deb638f5 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 4 Feb 2025 09:56:55 -0800 Subject: [PATCH 279/379] test: Refactor unit tests into group and object directories (#499) * refactor unit test into group and object directories * address codeQL warnings * fix import --- test/unit/groups/database_test.py | 192 ++++++++++++++++++ test/unit/groups/image_test.py | 37 ++++ test/unit/groups/linode_test.py | 118 +++++++++++ test/unit/groups/lke_test.py | 43 ++++ test/unit/groups/placement_test.py | 68 +++++++ test/unit/{objects => groups}/polling_test.py | 0 test/unit/groups/region_test.py | 51 +++++ test/unit/groups/vpc_test.py | 107 ++++++++++ test/unit/objects/database_test.py | 14 +- test/unit/objects/image_test.py | 30 --- test/unit/objects/linode_test.py | 111 +--------- test/unit/objects/lke_test.py | 30 --- test/unit/objects/placement_test.py | 42 ---- test/unit/objects/region_test.py | 43 ---- test/unit/objects/vpc_test.py | 49 ----- 15 files changed, 627 insertions(+), 308 deletions(-) create mode 100644 test/unit/groups/database_test.py create mode 100644 test/unit/groups/image_test.py create mode 100644 test/unit/groups/linode_test.py create mode 100644 test/unit/groups/lke_test.py create mode 100644 test/unit/groups/placement_test.py rename test/unit/{objects => groups}/polling_test.py (100%) create mode 100644 test/unit/groups/region_test.py create mode 100644 test/unit/groups/vpc_test.py diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py new file mode 100644 index 000000000..09d842b77 --- /dev/null +++ b/test/unit/groups/database_test.py @@ -0,0 +1,192 @@ +import logging +from test.unit.base import ClientBaseCase + +from linode_api4.objects import MySQLDatabase + +logger = logging.getLogger(__name__) + + +class DatabaseTest(ClientBaseCase): + """ + Tests methods of the DatabaseGroup class + """ + + def test_get_types(self): + """ + Test that database types are properly handled + """ + types = self.client.database.types() + + self.assertEqual(len(types), 1) + self.assertEqual(types[0].type_class, "nanode") + self.assertEqual(types[0].id, "g6-nanode-1") + self.assertEqual(types[0].engines.mysql[0].price.monthly, 20) + + def test_get_engines(self): + """ + Test that database engines are properly handled + """ + engines = self.client.database.engines() + + self.assertEqual(len(engines), 2) + + self.assertEqual(engines[0].engine, "mysql") + self.assertEqual(engines[0].id, "mysql/8.0.26") + self.assertEqual(engines[0].version, "8.0.26") + + self.assertEqual(engines[1].engine, "postgresql") + self.assertEqual(engines[1].id, "postgresql/10.14") + self.assertEqual(engines[1].version, "10.14") + + def test_get_databases(self): + """ + Test that databases are properly handled + """ + dbs = self.client.database.instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "8.0.26") + + def test_database_instance(self): + """ + Ensures that the .instance attribute properly translates database types + """ + + dbs = self.client.database.instances() + db_translated = dbs[0].instance + + self.assertTrue(isinstance(db_translated, MySQLDatabase)) + self.assertEqual(db_translated.ssl_connection, True) + + +class MySQLDatabaseTest(ClientBaseCase): + """ + Tests methods of the MySQLDatabase class + """ + + def test_get_instances(self): + """ + Test that database types are properly handled + """ + dbs = self.client.database.mysql_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "8.0.26") + + def test_create(self): + """ + Test that MySQL databases can be created + """ + + with self.mock_post("/databases/mysql/instances") as m: + # We don't care about errors here; we just want to + # validate the request. + try: + self.client.database.mysql_create( + "cool", + "us-southeast", + "mysql/8.0.26", + "g6-standard-1", + cluster_size=3, + ) + except Exception as e: + logger.warning( + "An error occurred while validating the request: %s", e + ) + + self.assertEqual(m.method, "post") + self.assertEqual(m.call_url, "/databases/mysql/instances") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["region"], "us-southeast") + self.assertEqual(m.call_data["engine"], "mysql/8.0.26") + self.assertEqual(m.call_data["type"], "g6-standard-1") + self.assertEqual(m.call_data["cluster_size"], 3) + + +class PostgreSQLDatabaseTest(ClientBaseCase): + """ + Tests methods of the PostgreSQLDatabase class + """ + + def test_get_instances(self): + """ + Test that database types are properly handled + """ + dbs = self.client.database.postgresql_instances() + + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "postgresql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-0000-000-pgsql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-0000-000-pgsql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "13.2") + + def test_create(self): + """ + Test that PostgreSQL databases can be created + """ + + with self.mock_post("/databases/postgresql/instances") as m: + # We don't care about errors here; we just want to + # validate the request. + try: + self.client.database.postgresql_create( + "cool", + "us-southeast", + "postgresql/13.2", + "g6-standard-1", + cluster_size=3, + ) + except Exception as e: + logger.warning( + "An error occurred while validating the request: %s", e + ) + + self.assertEqual(m.method, "post") + self.assertEqual(m.call_url, "/databases/postgresql/instances") + self.assertEqual(m.call_data["label"], "cool") + self.assertEqual(m.call_data["region"], "us-southeast") + self.assertEqual(m.call_data["engine"], "postgresql/13.2") + self.assertEqual(m.call_data["type"], "g6-standard-1") + self.assertEqual(m.call_data["cluster_size"], 3) diff --git a/test/unit/groups/image_test.py b/test/unit/groups/image_test.py new file mode 100644 index 000000000..e2aab386b --- /dev/null +++ b/test/unit/groups/image_test.py @@ -0,0 +1,37 @@ +from test.unit.base import ClientBaseCase + + +class ImageTest(ClientBaseCase): + """ + Tests methods of the Image class + """ + + def test_image_create_cloud_init(self): + """ + Test that an image can be created successfully with cloud-init. + """ + + with self.mock_post("images/private/123") as m: + self.client.images.create( + "Test Image", + "us-southeast", + description="very real image upload.", + cloud_init=True, + ) + + self.assertTrue(m.call_data["cloud_init"]) + + def test_image_create_upload_cloud_init(self): + """ + Test that an image upload URL can be created successfully with cloud-init. + """ + + with self.mock_post("images/upload") as m: + self.client.images.create_upload( + "Test Image", + "us-southeast", + description="very real image upload.", + cloud_init=True, + ) + + self.assertTrue(m.call_data["cloud_init"]) diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py new file mode 100644 index 000000000..8112a5d93 --- /dev/null +++ b/test/unit/groups/linode_test.py @@ -0,0 +1,118 @@ +from test.unit.base import ClientBaseCase + +from linode_api4 import InstancePlacementGroupAssignment +from linode_api4.objects import ConfigInterface + + +class LinodeTest(ClientBaseCase): + """ + Tests methods of the Linode class + """ + + def test_instance_create_with_user_data(self): + """ + Tests that the metadata field is populated on Linode create. + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-southeast", + metadata=self.client.linode.build_instance_metadata( + user_data="cool" + ), + ) + + self.assertEqual( + m.call_data, + { + "region": "us-southeast", + "type": "g6-nanode-1", + "metadata": {"user_data": "Y29vbA=="}, + }, + ) + + def test_instance_create_with_interfaces(self): + """ + Tests that user can pass a list of interfaces on Linode create. + """ + interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ] + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "us-southeast", + "g6-nanode-1", + interfaces=interfaces, + ) + + self.assertEqual( + m.call_data["interfaces"], + [ + {"purpose": "public"}, + { + "purpose": "vlan", + "label": "cool-vlan", + "ipam_address": "10.0.0.4/32", + }, + ], + ) + + def test_build_instance_metadata(self): + """ + Tests that the metadata field is built correctly. + """ + self.assertEqual( + self.client.linode.build_instance_metadata(user_data="cool"), + {"user_data": "Y29vbA=="}, + ) + + self.assertEqual( + self.client.linode.build_instance_metadata( + user_data="cool", encode_user_data=False + ), + {"user_data": "cool"}, + ) + + def test_create_with_placement_group(self): + """ + Tests that you can create a Linode with a Placement Group + """ + + with self.mock_post("linode/instances/123") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "eu-west", + placement_group=InstancePlacementGroupAssignment( + id=123, + compliant_only=True, + ), + ) + + self.assertEqual( + m.call_data["placement_group"], {"id": 123, "compliant_only": True} + ) + + +class TypeTest(ClientBaseCase): + def test_get_types(self): + """ + Tests that Linode types can be returned + """ + types = self.client.linode.types() + + self.assertEqual(len(types), 5) + for t in types: + self.assertTrue(t._populated) + self.assertIsNotNone(t.id) + self.assertIsNotNone(t.label) + self.assertIsNotNone(t.disk) + self.assertIsNotNone(t.type_class) + self.assertIsNotNone(t.gpus) + self.assertIsNone(t.successor) + self.assertIsNotNone(t.region_prices) + self.assertIsNotNone(t.addons.backups.region_prices) + self.assertIsNotNone(t.accelerated_devices) diff --git a/test/unit/groups/lke_test.py b/test/unit/groups/lke_test.py new file mode 100644 index 000000000..a39db81a6 --- /dev/null +++ b/test/unit/groups/lke_test.py @@ -0,0 +1,43 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + LKEClusterControlPlaneACLAddressesOptions, + LKEClusterControlPlaneACLOptions, + LKEClusterControlPlaneOptions, +) + + +class LKETest(ClientBaseCase): + """ + Tests methods of the LKE class + """ + + def test_cluster_create_with_acl(self): + """ + Tests that an LKE cluster can be created with a control plane ACL configuration. + """ + + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-mia", + "test-acl-cluster", + [self.client.lke.node_pool("g6-nanode-1", 3)], + "1.29", + control_plane=LKEClusterControlPlaneOptions( + acl=LKEClusterControlPlaneACLOptions( + enabled=True, + addresses=LKEClusterControlPlaneACLAddressesOptions( + ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] + ), + ) + ), + ) + + assert "high_availability" not in m.call_data["control_plane"] + assert m.call_data["control_plane"]["acl"]["enabled"] + assert m.call_data["control_plane"]["acl"]["addresses"]["ipv4"] == [ + "10.0.0.1/32" + ] + assert m.call_data["control_plane"]["acl"]["addresses"]["ipv6"] == [ + "1234::5678" + ] diff --git a/test/unit/groups/placement_test.py b/test/unit/groups/placement_test.py new file mode 100644 index 000000000..3c8337845 --- /dev/null +++ b/test/unit/groups/placement_test.py @@ -0,0 +1,68 @@ +from test.unit.base import ClientBaseCase + +from linode_api4 import PlacementGroupPolicy +from linode_api4.objects import ( + MigratedInstance, + PlacementGroup, + PlacementGroupMember, + PlacementGroupType, +) + + +class PlacementTest(ClientBaseCase): + """ + Tests methods of the Placement Group + """ + + def test_list_pgs(self): + """ + Tests that you can list PGs. + """ + + pgs = self.client.placement.groups() + + self.validate_pg_123(pgs[0]) + assert pgs[0]._populated + + def test_create_pg(self): + """ + Tests that you can create a Placement Group. + """ + + with self.mock_post("/placement/groups/123") as m: + pg = self.client.placement.group_create( + "test", + "eu-west", + PlacementGroupType.anti_affinity_local, + PlacementGroupPolicy.strict, + ) + + assert m.call_url == "/placement/groups" + + self.assertEqual( + m.call_data, + { + "label": "test", + "region": "eu-west", + "placement_group_type": str( + PlacementGroupType.anti_affinity_local + ), + "placement_group_policy": PlacementGroupPolicy.strict, + }, + ) + + assert pg._populated + self.validate_pg_123(pg) + + def validate_pg_123(self, pg: PlacementGroup): + assert pg.id == 123 + assert pg.label == "test" + assert pg.region.id == "eu-west" + assert pg.placement_group_type == "anti_affinity:local" + assert pg.placement_group_policy == "strict" + assert pg.is_compliant + assert pg.members[0] == PlacementGroupMember( + linode_id=123, is_compliant=True + ) + assert pg.migrations.inbound[0] == MigratedInstance(linode_id=123) + assert pg.migrations.outbound[0] == MigratedInstance(linode_id=456) diff --git a/test/unit/objects/polling_test.py b/test/unit/groups/polling_test.py similarity index 100% rename from test/unit/objects/polling_test.py rename to test/unit/groups/polling_test.py diff --git a/test/unit/groups/region_test.py b/test/unit/groups/region_test.py new file mode 100644 index 000000000..fe44c13ab --- /dev/null +++ b/test/unit/groups/region_test.py @@ -0,0 +1,51 @@ +import json +from test.unit.base import ClientBaseCase + +from linode_api4.objects.region import RegionAvailabilityEntry + + +class RegionTest(ClientBaseCase): + """ + Tests methods of the Region class + """ + + def test_list_availability(self): + """ + Tests that region availability can be listed and filtered on. + """ + + with self.mock_get("/regions/availability") as m: + avail_entries = self.client.regions.availability( + RegionAvailabilityEntry.filters.region == "us-east", + RegionAvailabilityEntry.filters.plan == "premium4096.7", + ) + + assert len(avail_entries) > 0 + + for entry in avail_entries: + assert entry.region is not None + assert len(entry.region) > 0 + + assert entry.plan is not None + assert len(entry.plan) > 0 + + assert entry.available is not None + + # Ensure all three pages are read + assert m.call_count == 3 + assert m.mock.call_args_list[0].args[0] == "//regions/availability" + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/availability?page=2&page_size=100" + ) + assert ( + m.mock.call_args_list[2].args[0] + == "//regions/availability?page=3&page_size=100" + ) + + # Ensure the filter headers are correct + for k, call in m.mock.call_args_list: + assert json.loads(call.get("headers").get("X-Filter")) == { + "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] + } diff --git a/test/unit/groups/vpc_test.py b/test/unit/groups/vpc_test.py new file mode 100644 index 000000000..7b8c985d2 --- /dev/null +++ b/test/unit/groups/vpc_test.py @@ -0,0 +1,107 @@ +import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import DATE_FORMAT, VPC, VPCSubnet + + +class VPCTest(ClientBaseCase): + """ + Tests methods of the VPC Group + """ + + def test_create_vpc(self): + """ + Tests that you can create a VPC. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create("test-vpc", "us-southeast") + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + + def test_create_vpc_with_subnet(self): + """ + Tests that you can create a VPC. + """ + + with self.mock_post("/vpcs/123456") as m: + vpc = self.client.vpcs.create( + "test-vpc", + "us-southeast", + subnets=[{"label": "test-subnet", "ipv4": "10.0.0.0/24"}], + ) + + self.assertEqual(m.call_url, "/vpcs") + + self.assertEqual( + m.call_data, + { + "label": "test-vpc", + "region": "us-southeast", + "subnets": [ + {"label": "test-subnet", "ipv4": "10.0.0.0/24"} + ], + }, + ) + + self.assertEqual(vpc._populated, True) + self.validate_vpc_123456(vpc) + + def test_list_ips(self): + """ + Validates that all VPC IPs can be listed. + """ + + with self.mock_get("/vpcs/ips") as m: + result = self.client.vpcs.ips() + + assert m.call_url == "/vpcs/ips" + assert len(result) == 1 + + ip = result[0] + assert ip.address == "10.0.0.2" + assert ip.address_range is None + assert ip.vpc_id == 123 + assert ip.subnet_id == 456 + assert ip.region == "us-mia" + assert ip.linode_id == 123 + assert ip.config_id == 456 + assert ip.interface_id == 789 + assert ip.active + assert ip.nat_1_1 == "172.233.179.133" + assert ip.gateway == "10.0.0.1" + assert ip.prefix == 24 + assert ip.subnet_mask == "255.255.255.0" + + def validate_vpc_123456(self, vpc: VPC): + expected_dt = datetime.datetime.strptime( + "2018-01-01T00:01:01", DATE_FORMAT + ) + + self.assertEqual(vpc.label, "test-vpc") + self.assertEqual(vpc.description, "A very real VPC.") + self.assertEqual(vpc.region.id, "us-southeast") + self.assertEqual(vpc.created, expected_dt) + self.assertEqual(vpc.updated, expected_dt) + + def validate_vpc_subnet_789(self, subnet: VPCSubnet): + expected_dt = datetime.datetime.strptime( + "2018-01-01T00:01:01", DATE_FORMAT + ) + + self.assertEqual(subnet.label, "test-subnet") + self.assertEqual(subnet.ipv4, "10.0.0.0/24") + self.assertEqual(subnet.linodes[0].id, 12345) + self.assertEqual(subnet.created, expected_dt) + self.assertEqual(subnet.updated, expected_dt) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 26250b7b2..11b2379aa 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -4,6 +4,8 @@ from linode_api4 import PostgreSQLDatabase from linode_api4.objects import MySQLDatabase +logger = logging.getLogger(__name__) + class DatabaseTest(ClientBaseCase): """ @@ -183,8 +185,10 @@ def test_create_backup(self): # validate the request. try: db.backup_create("mybackup", target="secondary") - except Exception: - pass + except Exception as e: + logger.warning( + "An error occurred while validating the request: %s", e + ) self.assertEqual(m.method, "post") self.assertEqual( @@ -366,8 +370,10 @@ def test_create_backup(self): # validate the request. try: db.backup_create("mybackup", target="secondary") - except Exception: - pass + except Exception as e: + logger.warning( + "An error occurred while validating the request: %s", e + ) self.assertEqual(m.method, "post") self.assertEqual( diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index f479d021f..0869919d6 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -114,36 +114,6 @@ def put_mock(url: str, data: Optional[BinaryIO] = None, **kwargs): self.assertEqual(image.tags[0], "test_tag") self.assertEqual(image.tags[1], "test2") - def test_image_create_cloud_init(self): - """ - Test that an image can be created successfully with cloud-init. - """ - - with self.mock_post("images/private/123") as m: - self.client.images.create( - "Test Image", - "us-southeast", - description="very real image upload.", - cloud_init=True, - ) - - self.assertTrue(m.call_data["cloud_init"]) - - def test_image_create_upload_cloud_init(self): - """ - Test that an image upload URL can be created successfully with cloud-init. - """ - - with self.mock_post("images/upload") as m: - self.client.images.create_upload( - "Test Image", - "us-southeast", - description="very real image upload.", - cloud_init=True, - ) - - self.assertTrue(m.call_data["cloud_init"]) - def test_image_replication(self): """ Test that image can be replicated. diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 44fea4f36..6016d2776 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,11 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import ( - InstanceDiskEncryptionType, - InstancePlacementGroupAssignment, - NetworkInterface, -) +from linode_api4 import InstanceDiskEncryptionType, NetworkInterface from linode_api4.objects import ( Config, ConfigInterface, @@ -444,74 +440,6 @@ def test_create_disk(self): assert disk.id == 12345 assert disk.disk_encryption == InstanceDiskEncryptionType.disabled - def test_instance_create_with_user_data(self): - """ - Tests that the metadata field is populated on Linode create. - """ - - with self.mock_post("linode/instances/123") as m: - self.client.linode.instance_create( - "g6-nanode-1", - "us-southeast", - metadata=self.client.linode.build_instance_metadata( - user_data="cool" - ), - ) - - self.assertEqual( - m.call_data, - { - "region": "us-southeast", - "type": "g6-nanode-1", - "metadata": {"user_data": "Y29vbA=="}, - }, - ) - - def test_instance_create_with_interfaces(self): - """ - Tests that user can pass a list of interfaces on Linode create. - """ - interfaces = [ - {"purpose": "public"}, - ConfigInterface( - purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" - ), - ] - with self.mock_post("linode/instances/123") as m: - self.client.linode.instance_create( - "us-southeast", - "g6-nanode-1", - interfaces=interfaces, - ) - - self.assertEqual( - m.call_data["interfaces"], - [ - {"purpose": "public"}, - { - "purpose": "vlan", - "label": "cool-vlan", - "ipam_address": "10.0.0.4/32", - }, - ], - ) - - def test_build_instance_metadata(self): - """ - Tests that the metadata field is built correctly. - """ - self.assertEqual( - self.client.linode.build_instance_metadata(user_data="cool"), - {"user_data": "Y29vbA=="}, - ) - - self.assertEqual( - self.client.linode.build_instance_metadata( - user_data="cool", encode_user_data=False - ), - {"user_data": "cool"}, - ) - def test_get_placement_group(self): """ Tests that you can get the placement group for a Linode @@ -535,25 +463,6 @@ def test_get_placement_group(self): assert pg.label == "test" assert pg.placement_group_type == "anti_affinity:local" - def test_create_with_placement_group(self): - """ - Tests that you can create a Linode with a Placement Group - """ - - with self.mock_post("linode/instances/123") as m: - self.client.linode.instance_create( - "g6-nanode-1", - "eu-west", - placement_group=InstancePlacementGroupAssignment( - id=123, - compliant_only=True, - ), - ) - - self.assertEqual( - m.call_data["placement_group"], {"id": 123, "compliant_only": True} - ) - class DiskTest(ClientBaseCase): """ @@ -663,24 +572,6 @@ def test_get_stackscript(self): class TypeTest(ClientBaseCase): - def test_get_types(self): - """ - Tests that Linode types can be returned - """ - types = self.client.linode.types() - - self.assertEqual(len(types), 5) - for t in types: - self.assertTrue(t._populated) - self.assertIsNotNone(t.id) - self.assertIsNotNone(t.label) - self.assertIsNotNone(t.disk) - self.assertIsNotNone(t.type_class) - self.assertIsNotNone(t.gpus) - self.assertIsNone(t.successor) - self.assertIsNotNone(t.region_prices) - self.assertIsNotNone(t.addons.backups.region_prices) - self.assertIsNotNone(t.accelerated_devices) def test_get_type_by_id(self): """ diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index c394e2f9a..1a39b69bc 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -167,36 +167,6 @@ def test_load_node_pool(self): self.assertIsNotNone(pool.autoscaler) self.assertIsNotNone(pool.tags) - def test_cluster_create_with_acl(self): - """ - Tests that an LKE cluster can be created with a control plane ACL configuration. - """ - - with self.mock_post("lke/clusters") as m: - self.client.lke.cluster_create( - "us-mia", - "test-acl-cluster", - [self.client.lke.node_pool("g6-nanode-1", 3)], - "1.29", - control_plane=LKEClusterControlPlaneOptions( - acl=LKEClusterControlPlaneACLOptions( - enabled=True, - addresses=LKEClusterControlPlaneACLAddressesOptions( - ipv4=["10.0.0.1/32"], ipv6=["1234::5678"] - ), - ) - ), - ) - - assert "high_availability" not in m.call_data["control_plane"] - assert m.call_data["control_plane"]["acl"]["enabled"] - assert m.call_data["control_plane"]["acl"]["addresses"]["ipv4"] == [ - "10.0.0.1/32" - ] - assert m.call_data["control_plane"]["acl"]["addresses"]["ipv6"] == [ - "1234::5678" - ] - def test_cluster_get_acl(self): """ Tests that an LKE cluster can be created with a control plane ACL configuration. diff --git a/test/unit/objects/placement_test.py b/test/unit/objects/placement_test.py index 4e5960e7b..08fcdc1e4 100644 --- a/test/unit/objects/placement_test.py +++ b/test/unit/objects/placement_test.py @@ -1,11 +1,9 @@ from test.unit.base import ClientBaseCase -from linode_api4 import PlacementGroupPolicy from linode_api4.objects import ( MigratedInstance, PlacementGroup, PlacementGroupMember, - PlacementGroupType, ) @@ -25,46 +23,6 @@ def test_get_placement_group(self): self.validate_pg_123(pg) assert pg._populated - def test_list_pgs(self): - """ - Tests that you can list PGs. - """ - - pgs = self.client.placement.groups() - - self.validate_pg_123(pgs[0]) - assert pgs[0]._populated - - def test_create_pg(self): - """ - Tests that you can create a Placement Group. - """ - - with self.mock_post("/placement/groups/123") as m: - pg = self.client.placement.group_create( - "test", - "eu-west", - PlacementGroupType.anti_affinity_local, - PlacementGroupPolicy.strict, - ) - - assert m.call_url == "/placement/groups" - - self.assertEqual( - m.call_data, - { - "label": "test", - "region": "eu-west", - "placement_group_type": str( - PlacementGroupType.anti_affinity_local - ), - "placement_group_policy": PlacementGroupPolicy.strict, - }, - ) - - assert pg._populated - self.validate_pg_123(pg) - def test_pg_assign(self): """ Tests that you can assign to a PG. diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index a7fcc2694..0bc1afa9e 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -1,8 +1,6 @@ -import json from test.unit.base import ClientBaseCase from linode_api4.objects import Region -from linode_api4.objects.region import RegionAvailabilityEntry class RegionTest(ClientBaseCase): @@ -30,47 +28,6 @@ def test_get_region(self): region.placement_group_limits.maximum_linodes_per_pg, 5 ) - def test_list_availability(self): - """ - Tests that region availability can be listed and filtered on. - """ - - with self.mock_get("/regions/availability") as m: - avail_entries = self.client.regions.availability( - RegionAvailabilityEntry.filters.region == "us-east", - RegionAvailabilityEntry.filters.plan == "premium4096.7", - ) - - assert len(avail_entries) > 0 - - for entry in avail_entries: - assert entry.region is not None - assert len(entry.region) > 0 - - assert entry.plan is not None - assert len(entry.plan) > 0 - - assert entry.available is not None - - # Ensure all three pages are read - assert m.call_count == 3 - assert m.mock.call_args_list[0].args[0] == "//regions/availability" - - assert ( - m.mock.call_args_list[1].args[0] - == "//regions/availability?page=2&page_size=100" - ) - assert ( - m.mock.call_args_list[2].args[0] - == "//regions/availability?page=3&page_size=100" - ) - - # Ensure the filter headers are correct - for k, call in m.mock.call_args_list: - assert json.loads(call.get("headers").get("X-Filter")) == { - "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] - } - def test_region_availability(self): """ Tests that availability for a specific region can be listed and filtered on. diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 20d36139b..5e7be1b69 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -30,55 +30,6 @@ def test_list_vpcs(self): self.validate_vpc_123456(vpcs[0]) self.assertEqual(vpcs[0]._populated, True) - def test_create_vpc(self): - """ - Tests that you can create a VPC. - """ - - with self.mock_post("/vpcs/123456") as m: - vpc = self.client.vpcs.create("test-vpc", "us-southeast") - - self.assertEqual(m.call_url, "/vpcs") - - self.assertEqual( - m.call_data, - { - "label": "test-vpc", - "region": "us-southeast", - }, - ) - - self.assertEqual(vpc._populated, True) - self.validate_vpc_123456(vpc) - - def test_create_vpc_with_subnet(self): - """ - Tests that you can create a VPC. - """ - - with self.mock_post("/vpcs/123456") as m: - vpc = self.client.vpcs.create( - "test-vpc", - "us-southeast", - subnets=[{"label": "test-subnet", "ipv4": "10.0.0.0/24"}], - ) - - self.assertEqual(m.call_url, "/vpcs") - - self.assertEqual( - m.call_data, - { - "label": "test-vpc", - "region": "us-southeast", - "subnets": [ - {"label": "test-subnet", "ipv4": "10.0.0.0/24"} - ], - }, - ) - - self.assertEqual(vpc._populated, True) - self.validate_vpc_123456(vpc) - def test_get_subnet(self): """ Tests that you can list VPCs. From 761734b280d7d04b140c9e6c8c98017858be22fb Mon Sep 17 00:00:00 2001 From: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Date: Tue, 4 Feb 2025 13:07:29 -0500 Subject: [PATCH 280/379] Support for dbaas v2 (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description **What does this PR do and why is this change necessary?** Adds new fields for dbaas 2 as well as a mehod for forking. ## ✔️ How to Test **How do I run the relevant unit/integration tests?** ```bash RUN_DB_FORK_TESTS=yes make testint TEST_SUITE=database ``` the tests are still very long so i left the skips in --------- Co-authored-by: ykim-1 Co-authored-by: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> --- .github/workflows/e2e-test-pr.yml | 18 +- .github/workflows/e2e-test.yml | 21 +- linode_api4/groups/database.py | 134 ++++++- linode_api4/objects/database.py | 13 +- .../models/database/test_database.py | 359 ++++++------------ 5 files changed, 290 insertions(+), 255 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 2e9908433..31e695aca 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -2,6 +2,22 @@ on: pull_request: workflow_dispatch: inputs: + run_db_fork_tests: + description: 'Set this parameter to "true" to run fork database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + run_db_tests: + description: 'Set this parameter to "true" to run database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' test_suite: description: 'Enter specific test suite. E.g. domain, linode_client' required: false @@ -80,7 +96,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make test-int 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 }} 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 142b2ff84..229aba540 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -3,6 +3,25 @@ name: Integration Tests on: workflow_dispatch: inputs: + run_db_fork_tests: + description: 'Set this parameter to "true" to run fork database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + run_db_tests: + description: 'Set this parameter to "true" to run database related test cases' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + test_suite: + description: 'Enter specific test suite. E.g. domain, linode_client' + required: false use_minimal_test_account: description: 'Indicate whether to use a minimal test account with limited resources for testing. Defaults to "false"' required: false @@ -72,7 +91,7 @@ jobs: run: | timestamp=$(date +'%Y%m%d%H%M') report_filename="${timestamp}_sdk_test_report.xml" - make test-int 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 }} TEST_SUITE="${{ github.event.inputs.test_suite }}" TEST_ARGS="--junitxml=${report_filename}" env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 957c136cf..8110ea888 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -1,13 +1,14 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - Base, Database, DatabaseEngine, DatabaseType, MySQLDatabase, PostgreSQLDatabase, + drop_null_keys, ) +from linode_api4.objects.base import _flatten_request_body_recursive class DatabaseGroup(Group): @@ -126,13 +127,71 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): params = { "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, + "region": region, + "engine": engine, + "type": ltype, } params.update(kwargs) - result = self.client.post("/databases/mysql/instances", data=params) + result = self.client.post( + "/databases/mysql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating MySQL Database", json=result + ) + + d = MySQLDatabase(self.client, result["id"], result) + return d + + def mysql_fork(self, source, restore_time, **kwargs): + """ + Forks an :any:`MySQLDatabase` on this account with + the given restore_time. label, region, engine, and ltype are optional. + For example:: + + client = LinodeClient(TOKEN) + + db_to_fork = client.database.mysql_instances()[0] + + new_fork = client.database.mysql_fork( + db_to_fork.id, + db_to_fork.updated, + label="new-fresh-label" + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instances + + :param source: The id of the source database + :type source: int + :param restore_time: The timestamp for the fork + :type restore_time: datetime + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str | Region + :param engine: The engine to deploy this cluster with + :type engine: str | Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str | Type + """ + + params = { + "fork": { + "source": source, + "restore_time": restore_time.strftime("%Y-%m-%dT%H:%M:%S"), + } + } + if "ltype" in kwargs: + params["type"] = kwargs["ltype"] + params.update(kwargs) + + result = self.client.post( + "/databases/mysql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) if "id" not in result: raise UnexpectedResponseError( @@ -191,14 +250,71 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): params = { "label": label, - "region": region.id if issubclass(type(region), Base) else region, - "engine": engine.id if issubclass(type(engine), Base) else engine, - "type": ltype.id if issubclass(type(ltype), Base) else ltype, + "region": region, + "engine": engine, + "type": ltype, + } + params.update(kwargs) + + result = self.client.post( + "/databases/postgresql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating PostgreSQL Database", + json=result, + ) + + d = PostgreSQLDatabase(self.client, result["id"], result) + return d + + def postgresql_fork(self, source, restore_time, **kwargs): + """ + Forks an :any:`PostgreSQLDatabase` on this account with + the given restore_time. label, region, engine, and ltype are optional. + For example:: + + client = LinodeClient(TOKEN) + + db_to_fork = client.database.postgresql_instances()[0] + + new_fork = client.database.postgresql_fork( + db_to_fork.id, + db_to_fork.updated, + label="new-fresh-label" + ) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgresql-instances + + :param source: The id of the source database + :type source: int + :param restore_time: The timestamp for the fork + :type restore_time: datetime + :param label: The name for this cluster + :type label: str + :param region: The region to deploy this cluster in + :type region: str | Region + :param engine: The engine to deploy this cluster with + :type engine: str | Engine + :param ltype: The Linode Type to use for this cluster + :type ltype: str | Type + """ + + params = { + "fork": { + "source": source, + "restore_time": restore_time.strftime("%Y-%m-%dT%H:%M:%S"), + } } + if "ltype" in kwargs: + params["type"] = kwargs["ltype"] params.update(kwargs) result = self.client.post( - "/databases/postgresql/instances", data=params + "/databases/postgresql/instances", + data=_flatten_request_body_recursive(drop_null_keys(params)), ) if "id" not in result: diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 6a028722c..ea833eb8a 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -131,7 +131,7 @@ class MySQLDatabase(Base): "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), "backups": Property(derived_class=MySQLDatabaseBackup), - "cluster_size": Property(), + "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), "engine": Property(), @@ -141,7 +141,9 @@ class MySQLDatabase(Base): "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), - "type": Property(), + "type": Property(mutable=True), + "fork": Property(), + "oldest_restore_time": Property(is_datetime=True), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), "version": Property(), @@ -264,7 +266,7 @@ class PostgreSQLDatabase(Base): "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), "backups": Property(derived_class=PostgreSQLDatabaseBackup), - "cluster_size": Property(), + "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), "engine": Property(), @@ -275,7 +277,9 @@ class PostgreSQLDatabase(Base): "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), - "type": Property(), + "type": Property(mutable=True), + "fork": Property(), + "oldest_restore_time": Property(is_datetime=True), "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), "version": Property(), @@ -414,6 +418,7 @@ class Database(Base): "region": Property(), "status": Property(), "type": Property(), + "fork": Property(), "updated": Property(), "updates": Property(), "version": Property(), diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index b9502abdc..5d8f74b41 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -1,4 +1,4 @@ -import re +import os import time from test.integration.helpers import ( get_test_label, @@ -35,9 +35,6 @@ def get_postgres_db_status(client: LinodeClient, db_id, status: str): @pytest.fixture(scope="session") def test_create_sql_db(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) client = test_linode_client label = get_test_label() + "-sqldb" region = "us-ord" @@ -65,9 +62,6 @@ def get_db_status(): @pytest.fixture(scope="session") def test_create_postgres_db(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) client = test_linode_client label = get_test_label() + "-postgresqldb" region = "us-ord" @@ -93,46 +87,90 @@ def get_db_status(): send_request_when_resource_available(300, db.delete) -# ------- SQL DB Test cases ------- -def test_get_types(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" +@pytest.mark.skipif( + os.getenv("RUN_DB_FORK_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_FORK_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_fork_sql_db(test_linode_client, test_create_sql_db): + client = test_linode_client + db_fork = client.database.mysql_fork( + test_create_sql_db.id, test_create_sql_db.updated + ) + + def get_db_fork_status(): + return db_fork.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_fork_status) + + assert db_fork.fork.source == test_create_sql_db.id + + db_fork.delete() + + +@pytest.mark.skipif( + os.getenv("RUN_DB_FORK_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_FORK_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_fork_postgres_db(test_linode_client, test_create_postgres_db): + client = test_linode_client + db_fork = client.database.postgresql_fork( + test_create_postgres_db.id, test_create_postgres_db.updated ) + + def get_db_fork_status(): + return db_fork.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_fork_status) + + assert db_fork.fork.source == test_create_postgres_db.id + + db_fork.delete() + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_types(test_linode_client): client = test_linode_client types = client.database.types() assert "nanode" in types[0].type_class assert "g6-nanode-1" in types[0].id - assert types[0].engines.mongodb[0].price.monthly == 15 +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_engines(test_linode_client): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) client = test_linode_client engines = client.database.engines() for e in engines: assert e.engine in ["mysql", "postgresql"] - assert re.search("[0-9]+.[0-9]+", e.version) + # assert re.search("[0-9]+.[0-9]+", e.version) assert e.id == e.engine + "/" + e.version +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_database_instance(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) dbs = test_linode_client.database.mysql_instances() assert str(test_create_sql_db.id) in str(dbs.lists) # ------- POSTGRESQL DB Test cases ------- +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_sql_db_instance(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) dbs = test_linode_client.database.mysql_instances() database = "" for db in dbs: @@ -143,13 +181,14 @@ def test_get_sql_db_instance(test_linode_client, test_create_sql_db): assert str(test_create_sql_db.label) == str(database.label) assert database.cluster_size == 1 assert database.engine == "mysql" - assert "-mysql-primary.servers.linodedb.net" in database.hosts.primary + assert ".g2a.akamaidb.net" in database.hosts.primary +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_update_sql_db(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) new_allow_list = ["192.168.0.1/32"] @@ -161,8 +200,6 @@ def test_update_sql_db(test_linode_client, test_create_sql_db): res = db.save() - database = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - wait_for_condition( 30, 300, @@ -172,111 +209,29 @@ def test_update_sql_db(test_linode_client, test_create_sql_db): "active", ) + database = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) + assert res assert database.allow_list == new_allow_list - assert database.label == label + # assert database.label == label assert database.updates.day_of_week == 2 -def test_create_sql_backup(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - label = "database_backup_test" - - wait_for_condition( - 30, - 300, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "active", - ) - - db.backup_create(label=label, target="secondary") - - wait_for_condition( - 10, - 300, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "backing_up", - ) - - assert db.status == "backing_up" - - # list backup and most recently created one is first element of the array - wait_for_condition( - 30, - 600, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "active", - ) - - backup = db.backups[0] - - assert backup.label == label - assert backup.database_id == test_create_sql_db.id - - assert db.status == "active" - - backup.delete() - - -def test_sql_backup_restore(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - try: - backup = db.backups[0] - except IndexError as e: - pytest.skip( - "Skipping this test. Reason: Couldn't find db backup instance" - ) - - backup.restore() - - wait_for_condition( - 10, - 300, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "restoring", - ) - - assert db.status == "restoring" - - wait_for_condition( - 30, - 1000, - get_sql_db_status, - test_linode_client, - test_create_sql_db.id, - "active", - ) - - assert db.status == "active" - - +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_sql_ssl(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) assert "ca_certificate" in str(db.ssl) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_sql_patch(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) db.patch() @@ -304,20 +259,22 @@ def test_sql_patch(test_linode_client, test_create_sql_db): assert db.status == "active" +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_sql_credentials(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) - assert db.credentials.username == "linroot" + assert db.credentials.username == "akmadmin" assert db.credentials.password +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_reset_sql_credentials(test_linode_client, test_create_sql_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) old_pass = str(db.credentials.password) @@ -327,17 +284,20 @@ def test_reset_sql_credentials(test_linode_client, test_create_sql_db): time.sleep(5) - assert db.credentials.username == "linroot" + assert db.credentials.username == "akmadmin" assert db.credentials.password != old_pass # ------- POSTGRESQL DB Test cases ------- +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) dbs = test_linode_client.database.postgresql_instances() + database = None + for db in dbs: if db.id == test_create_postgres_db.id: database = db @@ -346,13 +306,14 @@ def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): assert str(test_create_postgres_db.label) == str(database.label) assert database.cluster_size == 1 assert database.engine == "postgresql" - assert "pgsql-primary.servers.linodedb.net" in database.hosts.primary + assert "g2a.akamaidb.net" in database.hosts.primary +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_update_postgres_db(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) new_allow_list = ["192.168.0.1/32"] @@ -364,10 +325,6 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db): res = db.save() - database = test_linode_client.load( - PostgreSQLDatabase, test_create_postgres_db.id - ) - wait_for_condition( 30, 1000, @@ -377,111 +334,31 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db): "active", ) + database = test_linode_client.load( + PostgreSQLDatabase, test_create_postgres_db.id + ) + assert res assert database.allow_list == new_allow_list assert database.label == label assert database.updates.day_of_week == 2 -def test_create_postgres_backup(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - pytest.skip( - "Failing due to '400: The backup snapshot request failed, please contact support.'" - ) - db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - label = "database_backup_test" - - wait_for_condition( - 30, - 1000, - get_postgres_db_status, - test_linode_client, - test_create_postgres_db.id, - "active", - ) - - db.backup_create(label=label, target="secondary") - - # list backup and most recently created one is first element of the array - wait_for_condition( - 10, - 300, - get_sql_db_status, - test_linode_client, - test_create_postgres_db.id, - "backing_up", - ) - - assert db.status == "backing_up" - - # list backup and most recently created one is first element of the array - wait_for_condition( - 30, - 600, - get_sql_db_status, - test_linode_client, - test_create_postgres_db.id, - "active", - ) - - # list backup and most recently created one is first element of the array - backup = db.backups[0] - - assert backup.label == label - assert backup.database_id == test_create_postgres_db.id - - -def test_postgres_backup_restore(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) - db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - - try: - backup = db.backups[0] - except IndexError as e: - pytest.skip( - "Skipping this test. Reason: Couldn't find db backup instance" - ) - - backup.restore() - - wait_for_condition( - 30, - 1000, - get_postgres_db_status, - test_linode_client, - test_create_postgres_db.id, - "restoring", - ) - - wait_for_condition( - 30, - 1000, - get_postgres_db_status, - test_linode_client, - test_create_postgres_db.id, - "active", - ) - - assert db.status == "active" - - +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_postgres_ssl(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) assert "ca_certificate" in str(db.ssl) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_postgres_patch(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) db.patch() @@ -509,22 +386,24 @@ def test_postgres_patch(test_linode_client, test_create_postgres_db): assert db.status == "active" +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_get_postgres_credentials(test_linode_client, test_create_postgres_db): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) - assert db.credentials.username == "linpostgres" + assert db.credentials.username == "akmadmin" assert db.credentials.password +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) def test_reset_postgres_credentials( test_linode_client, test_create_postgres_db ): - pytest.skip( - "Might need Type to match how other object models are behaving e.g. client.load(Type, 123)" - ) db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) old_pass = str(db.credentials.password) @@ -533,5 +412,5 @@ def test_reset_postgres_credentials( time.sleep(5) - assert db.credentials.username == "linpostgres" + assert db.credentials.username == "akmadmin" assert db.credentials.password != old_pass From b23ac9ec09286a608ebd62fa93cfad487b5f0f6e Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 5 Feb 2025 14:53:31 -0500 Subject: [PATCH 281/379] Add support for Object Storage Gen 2 (#503) * wip * Added support for OBJ Gen 2 * Fix lint * Address PR comments * More PR comments --- linode_api4/groups/object_storage.py | 40 ++++++++++ linode_api4/objects/object_storage.py | 60 +++++++++++++-- .../object-storage_buckets_us-east-1.json | 4 +- ...rage_buckets_us-east-1_example-bucket.json | 4 +- ...buckets_us-east_example-bucket_access.json | 6 ++ .../models/object_storage/test_obj.py | 75 +++++++++++++++++-- test/unit/objects/object_storage_test.py | 27 +++++++ 7 files changed, 199 insertions(+), 17 deletions(-) create mode 100644 test/fixtures/object-storage_buckets_us-east_example-bucket_access.json diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index f531932e0..f13237c58 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -5,6 +5,11 @@ from deprecated import deprecated +from linode_api4 import ( + ObjectStorageEndpoint, + ObjectStorageEndpointType, + PaginatedList, +) from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -272,6 +277,30 @@ def transfer(self): return MappedObject(**result) + def endpoints(self, *filters) -> PaginatedList: + """ + Returns a paginated list of all Object Storage endpoints available in your account. + + This is intended to be called from the :any:`LinodeClient` + class, like this:: + + endpoints = client.object_storage.endpoints() + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-endpoints + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Object Storage Endpoints that matched the query. + :rtype: PaginatedList of ObjectStorageEndpoint + """ + return self.client._get_and_filter( + ObjectStorageEndpoint, + *filters, + endpoint="/object-storage/endpoints", + ) + def buckets(self, *filters): """ Returns a paginated list of all Object Storage Buckets that you own. @@ -299,6 +328,8 @@ def bucket_create( label: str, acl: ObjectStorageACL = ObjectStorageACL.PRIVATE, cors_enabled=False, + s3_endpoint: Optional[str] = None, + endpoint_type: Optional[ObjectStorageEndpointType] = None, ): """ Creates an Object Storage Bucket in the specified cluster. Accounts with @@ -320,6 +351,13 @@ def bucket_create( should be created. :type cluster: str + :param endpoint_type: The type of s3_endpoint available to the active user in this region. + :type endpoint_type: str + Enum: E0,E1,E2,E3 + + :param s3_endpoint: The active user's s3 endpoint URL, based on the endpoint_type and region. + :type s3_endpoint: str + :param cors_enabled: If true, the bucket will be created with CORS enabled for all origins. For more fine-grained controls of CORS, use the S3 API directly. @@ -346,6 +384,8 @@ def bucket_create( "label": label, "acl": acl, "cors_enabled": cors_enabled, + "s3_endpoint": s3_endpoint, + "endpoint_type": endpoint_type, } if self.is_cluster(cluster_or_region_id): diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index f4ddfe9b5..76a3945e2 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Optional from urllib import parse @@ -11,7 +12,7 @@ Property, Region, ) -from linode_api4.objects.serializable import StrEnum +from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.util import drop_null_keys @@ -28,6 +29,27 @@ class ObjectStorageKeyPermission(StrEnum): READ_WRITE = "read_write" +class ObjectStorageEndpointType(StrEnum): + E0 = "E0" + E1 = "E1" + E2 = "E2" + E3 = "E3" + + +@dataclass +class ObjectStorageEndpoint(JSONObject): + """ + ObjectStorageEndpoint contains the core fields of an object storage endpoint object. + + NOTE: This is not implemented as a typical API object (Base) because Object Storage Endpoints + cannot be refreshed, as there is no singular GET endpoint. + """ + + region: str = "" + endpoint_type: ObjectStorageEndpointType = "" + s3_endpoint: Optional[str] = None + + class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. @@ -47,6 +69,8 @@ class ObjectStorageBucket(DerivedBase): "label": Property(identifier=True), "objects": Property(), "size": Property(), + "endpoint_type": Property(), + "s3_endpoint": Property(), } @classmethod @@ -63,13 +87,8 @@ def make_instance(cls, id, client, parent_id=None, json=None): Override this method to pass in the parent_id from the _raw_json object when it's available. """ - if json is None: - return None - - cluster_or_region = json.get("region") or json.get("cluster") - - if parent_id is None and cluster_or_region: - parent_id = cluster_or_region + if json is not None: + parent_id = parent_id or json.get("region") or json.get("cluster") if parent_id: return super().make(id, client, cls, parent_id=parent_id, json=json) @@ -78,6 +97,31 @@ def make_instance(cls, id, client, parent_id=None, json=None): "Unexpected json response when making a new Object Storage Bucket instance." ) + def access_get(self): + """ + Returns a result object which wraps the current access config for this ObjectStorageBucket. + + API Documentation: TODO + + :returns: A result object which wraps the access that this ObjectStorageBucket is currently configured with. + :rtype: MappedObject + """ + result = self._client.get( + "{}/access".format(self.api_endpoint), + model=self, + ) + + if not any( + key in result + for key in ["acl", "acl_xml", "cors_enabled", "cors_xml"] + ): + raise UnexpectedResponseError( + "Unexpected response when getting the bucket access config of a bucket!", + json=result, + ) + + return MappedObject(**result) + def access_modify( self, acl: Optional[ObjectStorageACL] = None, diff --git a/test/fixtures/object-storage_buckets_us-east-1.json b/test/fixtures/object-storage_buckets_us-east-1.json index f99a944a6..f1479dabb 100644 --- a/test/fixtures/object-storage_buckets_us-east-1.json +++ b/test/fixtures/object-storage_buckets_us-east-1.json @@ -6,7 +6,9 @@ "hostname": "example-bucket.us-east-1.linodeobjects.com", "label": "example-bucket", "objects": 4, - "size": 188318981 + "size": 188318981, + "endpoint_type": "E1", + "s3_endpoint": "us-east-12.linodeobjects.com" } ], "page": 1, diff --git a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json index bb93ec99a..c9c6344ee 100644 --- a/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json +++ b/test/fixtures/object-storage_buckets_us-east-1_example-bucket.json @@ -5,5 +5,7 @@ "hostname": "example-bucket.us-east-1.linodeobjects.com", "label": "example-bucket", "objects": 4, - "size": 188318981 + "size": 188318981, + "endpoint_type": "E1", + "s3_endpoint": "us-east-12.linodeobjects.com" } \ No newline at end of file diff --git a/test/fixtures/object-storage_buckets_us-east_example-bucket_access.json b/test/fixtures/object-storage_buckets_us-east_example-bucket_access.json new file mode 100644 index 000000000..852803146 --- /dev/null +++ b/test/fixtures/object-storage_buckets_us-east_example-bucket_access.json @@ -0,0 +1,6 @@ +{ + "acl": "authenticated-read", + "acl_xml": "..." +} \ No newline at end of file diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 82b2da022..0f3e39f33 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -8,6 +8,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageEndpointType, ObjectStorageKeyPermission, ObjectStorageKeys, ) @@ -19,7 +20,14 @@ def region(test_linode_client: LinodeClient): @pytest.fixture(scope="session") -def bucket(test_linode_client: LinodeClient, region: str): +def endpoints(test_linode_client: LinodeClient): + return test_linode_client.object_storage.endpoints() + + +@pytest.fixture(scope="session") +def bucket( + test_linode_client: LinodeClient, region: str +) -> ObjectStorageBucket: bucket = test_linode_client.object_storage.bucket_create( cluster_or_region=region, label="bucket-" + str(time.time_ns()), @@ -31,6 +39,31 @@ def bucket(test_linode_client: LinodeClient, region: str): bucket.delete() +@pytest.fixture(scope="session") +def bucket_with_endpoint( + test_linode_client: LinodeClient, endpoints +) -> ObjectStorageBucket: + selected_endpoint = next( + ( + e + for e in endpoints + if e.endpoint_type == ObjectStorageEndpointType.E1 + ), + None, + ) + + bucket = test_linode_client.object_storage.bucket_create( + cluster_or_region=selected_endpoint.region, + label="bucket-" + str(time.time_ns()), + acl=ObjectStorageACL.PRIVATE, + cors_enabled=False, + endpoint_type=selected_endpoint.endpoint_type, + ) + + yield bucket + bucket.delete() + + @pytest.fixture(scope="session") def obj_key(test_linode_client: LinodeClient): key = test_linode_client.object_storage.keys_create( @@ -71,19 +104,39 @@ def test_keys( assert loaded_key.label == obj_key.label assert loaded_limited_key.label == obj_limited_key.label + assert ( + loaded_limited_key.regions[0].endpoint_type + in ObjectStorageEndpointType.__members__.values() + ) -def test_bucket( - test_linode_client: LinodeClient, - bucket: ObjectStorageBucket, -): - loaded_bucket = test_linode_client.load(ObjectStorageBucket, bucket.label) +def test_bucket(test_linode_client: LinodeClient, bucket: ObjectStorageBucket): + loaded_bucket = test_linode_client.load( + ObjectStorageBucket, + target_id=bucket.label, + target_parent_id=bucket.region, + ) assert loaded_bucket.label == bucket.label assert loaded_bucket.region == bucket.region -def test_bucket( +def test_bucket_with_endpoint( + test_linode_client: LinodeClient, bucket_with_endpoint: ObjectStorageBucket +): + loaded_bucket = test_linode_client.load( + ObjectStorageBucket, + target_id=bucket_with_endpoint.label, + target_parent_id=bucket_with_endpoint.region, + ) + + assert loaded_bucket.label == bucket_with_endpoint.label + assert loaded_bucket.region == bucket_with_endpoint.region + assert loaded_bucket.s3_endpoint is not None + assert loaded_bucket.endpoint_type == "E1" + + +def test_buckets_in_region( test_linode_client: LinodeClient, bucket: ObjectStorageBucket, region: str, @@ -103,6 +156,14 @@ def test_list_obj_storage_bucket( assert any(target_bucket_id == b.id for b in buckets) +def test_bucket_access_get(bucket: ObjectStorageBucket): + access = bucket.access_get() + + assert access.acl is not None + assert access.acl_xml is not None + assert access.cors_enabled is not None + + def test_bucket_access_modify(bucket: ObjectStorageBucket): bucket.access_modify(ObjectStorageACL.PRIVATE, cors_enabled=True) diff --git a/test/unit/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py index 95d781a84..396813b3d 100644 --- a/test/unit/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -1,6 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import ObjectStorageEndpointType from linode_api4.objects import ( ObjectStorageACL, ObjectStorageBucket, @@ -35,6 +36,14 @@ def test_object_storage_bucket_api_get(self): ) self.assertEqual(object_storage_bucket.objects, 4) self.assertEqual(object_storage_bucket.size, 188318981) + self.assertEqual( + object_storage_bucket.endpoint_type, + ObjectStorageEndpointType.E1, + ) + self.assertEqual( + object_storage_bucket.s3_endpoint, + "us-east-12.linodeobjects.com", + ) self.assertEqual(m.call_url, object_storage_bucket_api_get_url) def test_object_storage_bucket_delete(self): @@ -48,6 +57,22 @@ def test_object_storage_bucket_delete(self): object_storage_bucket.delete() self.assertEqual(m.call_url, object_storage_bucket_delete_url) + def test_bucket_access_get(self): + bucket_access_get_url = ( + "/object-storage/buckets/us-east/example-bucket/access" + ) + with self.mock_get(bucket_access_get_url) as m: + object_storage_bucket = ObjectStorageBucket( + self.client, "example-bucket", "us-east" + ) + result = object_storage_bucket.access_get() + self.assertIsNotNone(result) + self.assertEqual(m.call_url, bucket_access_get_url) + self.assertEqual(result.acl, "authenticated-read") + self.assertEqual(result.cors_enabled, True) + self.assertEqual(result.acl_xml, "...") + def test_bucket_access_modify(self): """ Test that you can modify bucket access settings. @@ -115,6 +140,8 @@ def test_buckets_in_cluster(self): self.assertEqual(bucket.label, "example-bucket") self.assertEqual(bucket.objects, 4) self.assertEqual(bucket.size, 188318981) + self.assertEqual(bucket.endpoint_type, ObjectStorageEndpointType.E1) + self.assertEqual(bucket.s3_endpoint, "us-east-12.linodeobjects.com") def test_ssl_cert_delete(self): """ From 5852396ed028f168628526c7dbb1ad05ea2e57e3 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 10 Feb 2025 10:41:18 -0500 Subject: [PATCH 282/379] Deprecate methods and classes related to database backups (#506) * Deprecate DBaaS backup-related methods & classes * Fix import --- linode_api4/objects/database.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index ea833eb8a..58044edb0 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,3 +1,5 @@ +from deprecated import deprecated + from linode_api4.objects import Base, DerivedBase, MappedObject, Property @@ -63,6 +65,9 @@ def invalidate(self): Base.invalidate(self) +@deprecated( + reason="Backups are not supported for non-legacy database clusters." +) class DatabaseBackup(DerivedBase): """ A generic Managed Database backup. @@ -97,6 +102,9 @@ def restore(self): ) +@deprecated( + reason="Backups are not supported for non-legacy database clusters." +) class MySQLDatabaseBackup(DatabaseBackup): """ A backup for an accessible Managed MySQL Database. @@ -107,6 +115,9 @@ class MySQLDatabaseBackup(DatabaseBackup): api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" +@deprecated( + reason="Backups are not supported for non-legacy database clusters." +) class PostgreSQLDatabaseBackup(DatabaseBackup): """ A backup for an accessible Managed PostgreSQL Database. @@ -221,6 +232,9 @@ def patch(self): "{}/patch".format(MySQLDatabase.api_endpoint), model=self ) + @deprecated( + reason="Backups are not supported for non-legacy database clusters." + ) def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed MySQL Database. @@ -358,6 +372,9 @@ def patch(self): "{}/patch".format(PostgreSQLDatabase.api_endpoint), model=self ) + @deprecated( + reason="Backups are not supported for non-legacy database clusters." + ) def backup_create(self, label, **kwargs): """ Creates a snapshot backup of a Managed PostgreSQL Database. From b51634d8348ab2616cbfd1b03dd5534767a6a0d1 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:48:00 -0800 Subject: [PATCH 283/379] Add default string value for RUN_DB_TESTS (#509) --- .../models/database/test_database.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index 5d8f74b41..d4f3c8796 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -130,7 +130,7 @@ def get_db_fork_status(): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_types(test_linode_client): @@ -142,7 +142,7 @@ def test_get_types(test_linode_client): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_engines(test_linode_client): @@ -156,7 +156,7 @@ def test_get_engines(test_linode_client): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_database_instance(test_linode_client, test_create_sql_db): @@ -167,7 +167,7 @@ def test_database_instance(test_linode_client, test_create_sql_db): # ------- POSTGRESQL DB Test cases ------- @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_sql_db_instance(test_linode_client, test_create_sql_db): @@ -185,7 +185,7 @@ def test_get_sql_db_instance(test_linode_client, test_create_sql_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_update_sql_db(test_linode_client, test_create_sql_db): @@ -218,7 +218,7 @@ def test_update_sql_db(test_linode_client, test_create_sql_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_sql_ssl(test_linode_client, test_create_sql_db): @@ -228,7 +228,7 @@ def test_get_sql_ssl(test_linode_client, test_create_sql_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_sql_patch(test_linode_client, test_create_sql_db): @@ -260,7 +260,7 @@ def test_sql_patch(test_linode_client, test_create_sql_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_sql_credentials(test_linode_client, test_create_sql_db): @@ -271,7 +271,7 @@ def test_get_sql_credentials(test_linode_client, test_create_sql_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_reset_sql_credentials(test_linode_client, test_create_sql_db): @@ -290,7 +290,7 @@ def test_reset_sql_credentials(test_linode_client, test_create_sql_db): # ------- POSTGRESQL DB Test cases ------- @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): @@ -310,7 +310,7 @@ def test_get_postgres_db_instance(test_linode_client, test_create_postgres_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_update_postgres_db(test_linode_client, test_create_postgres_db): @@ -345,7 +345,7 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_postgres_ssl(test_linode_client, test_create_postgres_db): @@ -355,7 +355,7 @@ def test_get_postgres_ssl(test_linode_client, test_create_postgres_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_postgres_patch(test_linode_client, test_create_postgres_db): @@ -387,7 +387,7 @@ def test_postgres_patch(test_linode_client, test_create_postgres_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_get_postgres_credentials(test_linode_client, test_create_postgres_db): @@ -398,7 +398,7 @@ def test_get_postgres_credentials(test_linode_client, test_create_postgres_db): @pytest.mark.skipif( - os.getenv("RUN_DB_TESTS").strip().lower() not in {"yes", "true"}, + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", ) def test_reset_postgres_credentials( From f265d41368d3f4b90ce25123123df7b345d98b27 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 13 Feb 2025 01:48:17 -0500 Subject: [PATCH 284/379] Remove sensitive info output in testing (#512) --- test/integration/models/database/test_database.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index d4f3c8796..1ad5dde3b 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -278,12 +278,9 @@ def test_reset_sql_credentials(test_linode_client, test_create_sql_db): db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) old_pass = str(db.credentials.password) - - print(old_pass) db.credentials_reset() time.sleep(5) - assert db.credentials.username == "akmadmin" assert db.credentials.password != old_pass From 4e590c11b9d51e582b8a3e5eb7f319b49cc116d6 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:12:46 -0800 Subject: [PATCH 285/379] add disabling acl tc (#511) --- test/integration/models/lke/test_lke.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index f2fb3f2e5..7355ca40b 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -297,6 +297,22 @@ def test_lke_cluster_acl(lke_cluster_with_acl): assert acl == cluster.control_plane_acl assert acl.addresses.ipv4 == ["10.0.0.2/32"] + +def test_lke_cluster_disable_acl(lke_cluster_with_acl): + cluster = lke_cluster_with_acl + + assert cluster.control_plane_acl.enabled + + acl = cluster.control_plane_acl_update( + LKEClusterControlPlaneACLOptions( + enabled=False, + ) + ) + + assert acl.enabled is False + assert acl == cluster.control_plane_acl + assert acl.addresses.ipv4 == ["10.0.0.2/32"] + cluster.control_plane_acl_delete() assert not cluster.control_plane_acl.enabled From e5383773f7c31779e94acd31046a5d3fe08d84aa Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:55:38 -0500 Subject: [PATCH 286/379] Remove LKE ACL LA notices (#513) --- linode_api4/objects/lke.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index e675eae8e..12d21f21f 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -79,8 +79,6 @@ class LKEClusterControlPlaneACLOptions(JSONObject): """ LKEClusterControlPlaneACLOptions is used to set the ACL configuration of an LKE cluster's control plane. - - NOTE: Control Plane ACLs may not currently be available to all users. """ enabled: Optional[bool] = None @@ -116,8 +114,6 @@ class LKEClusterControlPlaneACL(JSONObject): """ LKEClusterControlPlaneACL describes the ACL configuration of an LKE cluster's control plane. - - NOTE: Control Plane ACLs may not currently be available to all users. """ include_none_values = True @@ -337,8 +333,6 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: """ Gets the ACL configuration of this cluster's control plane. - NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-acl :returns: The cluster's control plane ACL configuration. @@ -558,8 +552,6 @@ def control_plane_acl_update( """ Updates the ACL configuration for this cluster's control plane. - NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/put-lke-cluster-acl :param acl: The ACL configuration to apply to this cluster. @@ -589,8 +581,6 @@ def control_plane_acl_delete(self): This has the same effect as calling control_plane_acl_update with the `enabled` field set to False. Access controls are disabled and all rules are deleted. - NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-cluster-acl """ self._client.delete( From baee4dcc2cf4450efe2344701575c461a8769452 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Fri, 28 Feb 2025 09:21:45 -0500 Subject: [PATCH 287/379] Drop the LKE ACL `addresses` field when its value is null (#514) * Drop addresses key from ACL if None * Fix create * Add creation unit test * Account for LKE cluster ACL API change * make format --- linode_api4/groups/lke.py | 2 +- linode_api4/objects/lke.py | 3 +- test/integration/models/lke/test_lke.py | 21 ++++++++++++-- test/unit/objects/lke_test.py | 38 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index d64d45536..4d13bb650 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -131,7 +131,7 @@ def cluster_create( result = self.client.post( "/lke/clusters", - data=_flatten_request_body_recursive(drop_null_keys(params)), + data=drop_null_keys(_flatten_request_body_recursive(params)), ) if "id" not in result: diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 12d21f21f..2f670f2b9 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -14,6 +14,7 @@ Region, Type, ) +from linode_api4.util import drop_null_keys class LKEType(Base): @@ -566,7 +567,7 @@ def control_plane_acl_update( result = self._client.put( f"{LKECluster.api_endpoint}/control_plane_acl", model=self, - data={"acl": acl}, + data={"acl": drop_null_keys(acl)}, ) acl = result.get("acl") diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 7355ca40b..794bc3203 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -45,7 +45,7 @@ def lke_cluster(test_linode_client): cluster.delete() -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def lke_cluster_with_acl(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] @@ -288,9 +288,10 @@ def test_lke_cluster_acl(lke_cluster_with_acl): acl = cluster.control_plane_acl_update( LKEClusterControlPlaneACLOptions( + enabled=True, addresses=LKEClusterControlPlaneACLAddressesOptions( ipv4=["10.0.0.2/32"] - ) + ), ) ) @@ -298,6 +299,20 @@ def test_lke_cluster_acl(lke_cluster_with_acl): assert acl.addresses.ipv4 == ["10.0.0.2/32"] +def test_lke_cluster_update_acl_null_addresses(lke_cluster_with_acl): + cluster = lke_cluster_with_acl + + # Addresses should not be included in the request if it's null, + # else an error will be returned by the API. + # See: TPT-3489 + acl = cluster.control_plane_acl_update( + {"enabled": False, "addresses": None} + ) + + assert acl == cluster.control_plane_acl + assert acl.addresses.ipv4 == [] + + def test_lke_cluster_disable_acl(lke_cluster_with_acl): cluster = lke_cluster_with_acl @@ -311,7 +326,7 @@ def test_lke_cluster_disable_acl(lke_cluster_with_acl): assert acl.enabled is False assert acl == cluster.control_plane_acl - assert acl.addresses.ipv4 == ["10.0.0.2/32"] + assert acl.addresses.ipv4 == [] cluster.control_plane_acl_delete() diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 1a39b69bc..1f397afac 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -498,3 +498,41 @@ def test_populate_with_mixed_types(self): assert self.pool.nodes[0].id == "node7" assert self.pool.nodes[1].id == "node8" assert self.pool.nodes[2].id == "node9" + + def test_cluster_create_acl_null_addresses(self): + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + region="us-mia", + label="foobar", + kube_version="1.32", + node_pools=[self.client.lke.node_pool("g6-standard-1", 3)], + control_plane={ + "acl": { + "enabled": False, + "addresses": None, + } + }, + ) + + # Addresses should not be included in the API request if it's null + # See: TPT-3489 + assert m.call_data["control_plane"] == { + "acl": { + "enabled": False, + } + } + + def test_cluster_update_acl_null_addresses(self): + cluster = LKECluster(self.client, 18881) + + with self.mock_put("lke/clusters/18881/control_plane_acl") as m: + cluster.control_plane_acl_update( + { + "enabled": True, + "addresses": None, + } + ) + + # Addresses should not be included in the API request if it's null + # See: TPT-3489 + assert m.call_data == {"acl": {"enabled": True}} From 6cf8071322cd8d0d526486f863b11e058e79f3b0 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:51:41 -0800 Subject: [PATCH 288/379] Improve slack test notifications (#516) * gha test 1 * update submodule * add test summary * gha test 2 * gha test 3 * gha test 4 * gha test 5 * gha test 6 * gha test 7 * gha test 8 * gha test 9 * gha test 10 * gha test 11 * gha test 12 * gha test 13 * gha test 13 * gha test 14 * gha test 15 * gha test 16 * gha test 17 * gha test 18 * improve slack test notification --- .github/workflows/e2e-test.yml | 32 ++++++++++++++++++++++++++++---- e2e_scripts | 2 +- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 229aba540..c0ccc8e87 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -173,6 +173,8 @@ jobs: runs-on: ubuntu-latest needs: [integration-tests] if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + outputs: + summary: ${{ steps.set-test-summary.outputs.summary }} steps: - name: Checkout code @@ -197,7 +199,6 @@ jobs: - name: Set release version env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - name: Add variables and upload test results if: always() run: | @@ -213,12 +214,24 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + - name: Generate test summary and save to output + id: set-test-summary + run: | + filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') + test_output=$(python3 e2e_scripts/tod_scripts/generate_test_summary.py "${filename}") + { + echo 'summary<> "$GITHUB_OUTPUT" + notify-slack: runs-on: ubuntu-latest - needs: [integration-tests] + needs: [integration-tests, process-upload-report] if: ${{ (success() || failure()) }} # Run even if integration tests fail and only on main repository steps: - name: Notify Slack + id: main_message uses: slackapi/slack-github-action@v2.0.0 with: method: chat.postMessage @@ -229,7 +242,7 @@ jobs: - type: section text: type: mrkdwn - text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* :white_check_mark:" + text: ":rocket: *${{ github.workflow }} Completed in: ${{ github.repository }}* ${{ needs.integration-tests.result == 'success' && ':white_check_mark:' || ':failed:' }}" - type: divider - type: section fields: @@ -247,4 +260,15 @@ jobs: - type: context elements: - type: mrkdwn - text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" \ No newline at end of file + text: "Triggered by: :bust_in_silhouette: `${{ github.actor }}`" + + - name: Test summary thread + if: success() + uses: slackapi/slack-github-action@v2.0.0 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID }} + thread_ts: "${{ steps.main_message.outputs.ts }}" + text: "${{ needs.process-upload-report.outputs.summary }}" diff --git a/e2e_scripts b/e2e_scripts index 0f2ff0169..5d0054351 160000 --- a/e2e_scripts +++ b/e2e_scripts @@ -1 +1 @@ -Subproject commit 0f2ff016956090c6fff046f4479e7efe8d0086e5 +Subproject commit 5d0054351277faa68a224172674210795cb36646 From a24d29738ca326638669bcdde4c5db5604265f37 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:40:37 -0500 Subject: [PATCH 289/379] Add missed filters to supported endpoints (#508) --- linode_api4/groups/account.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index c2c69c624..564e55eea 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -101,7 +101,7 @@ def settings(self): s = AccountSettings(self.client, result["managed"], result) return s - def invoices(self): + def invoices(self, *filters): """ Returns Invoices issued to this account. @@ -112,9 +112,9 @@ def invoices(self): :returns: Invoices issued to this account. :rtype: PaginatedList of Invoice """ - return self.client._get_and_filter(Invoice) + return self.client._get_and_filter(Invoice, *filters) - def payments(self): + def payments(self, *filters): """ Returns a list of Payments made on this account. @@ -123,7 +123,7 @@ def payments(self): :returns: A list of payments made on this account. :rtype: PaginatedList of Payment """ - return self.client._get_and_filter(Payment) + return self.client._get_and_filter(Payment, *filters) def oauth_clients(self, *filters): """ @@ -337,7 +337,7 @@ def add_promo_code(self, promo_code): json=resp, ) - def service_transfers(self): + def service_transfers(self, *filters): """ Returns a collection of all created and accepted Service Transfers for this account, regardless of the user that created or accepted the transfer. @@ -347,7 +347,7 @@ def service_transfers(self): :rtype: PaginatedList of ServiceTransfer """ - return self.client._get_and_filter(ServiceTransfer) + return self.client._get_and_filter(ServiceTransfer, *filters) def service_transfer_create(self, entities): """ From f424bd212ac55e1ae0540a485be91433441abb17 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 3 Mar 2025 17:02:18 -0800 Subject: [PATCH 290/379] update submodule (#517) --- e2e_scripts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e_scripts b/e2e_scripts index 5d0054351..3265074d0 160000 --- a/e2e_scripts +++ b/e2e_scripts @@ -1 +1 @@ -Subproject commit 5d0054351277faa68a224172674210795cb36646 +Subproject commit 3265074d0d7ff8db6ce5207084051e1fc45d0763 From e1d8881271ddfd6d633525ab79761e75ba84c3de Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 11 Mar 2025 09:43:50 -0400 Subject: [PATCH 291/379] Support listing Object Storage Types (#518) * Support listing Object Storage Types * Addressed PR comments --- linode_api4/groups/object_storage.py | 19 +++++++++++++++ linode_api4/objects/object_storage.py | 19 +++++++++++++++ test/fixtures/object-storage_types.json | 23 +++++++++++++++++++ .../models/object_storage/test_obj.py | 21 +++++++++++++++++ test/unit/linode_client_test.py | 15 ++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 test/fixtures/object-storage_types.json diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index f13237c58..eb6a296b7 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -8,6 +8,7 @@ from linode_api4 import ( ObjectStorageEndpoint, ObjectStorageEndpointType, + ObjectStorageType, PaginatedList, ) from linode_api4.errors import UnexpectedResponseError @@ -70,6 +71,24 @@ def keys(self, *filters): """ return self.client._get_and_filter(ObjectStorageKeys, *filters) + def types(self, *filters): + """ + Returns a paginated list of Object Storage Types. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-types + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A Paginated List of Object Storage types that match the query. + :rtype: PaginatedList of ObjectStorageType + """ + + return self.client._get_and_filter( + ObjectStorageType, *filters, endpoint="/object-storage/types" + ) + def keys_create( self, label: str, diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 76a3945e2..be1fd0cc7 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -4,6 +4,7 @@ from deprecated import deprecated +from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import ( Base, @@ -50,6 +51,24 @@ class ObjectStorageEndpoint(JSONObject): s3_endpoint: Optional[str] = None +class ObjectStorageType(Base): + """ + An ObjectStorageType represents the structure of a valid Object Storage type. + Currently, the ObjectStorageType can only be retrieved by listing, i.e.: + types = client.object_storage.types() + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-types + """ + + properties = { + "id": Property(identifier=True), + "label": Property(), + "price": Property(json_object=Price), + "region_prices": Property(json_object=RegionPrice), + "transfer": Property(), + } + + class ObjectStorageBucket(DerivedBase): """ A bucket where objects are stored in. diff --git a/test/fixtures/object-storage_types.json b/test/fixtures/object-storage_types.json new file mode 100644 index 000000000..029823580 --- /dev/null +++ b/test/fixtures/object-storage_types.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "id": "objectstorage", + "label": "Object Storage", + "price": { + "hourly": 0.0015, + "monthly": 0.1 + }, + "region_prices": [ + { + "hourly": 0.00018, + "id": "us-east", + "monthly": 0.12 + } + ], + "transfer": 0 + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 0f3e39f33..33ce8dfbe 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -3,6 +3,7 @@ import pytest +from linode_api4.common import RegionPrice from linode_api4.linode_client import LinodeClient from linode_api4.objects.object_storage import ( ObjectStorageACL, @@ -11,6 +12,7 @@ ObjectStorageEndpointType, ObjectStorageKeyPermission, ObjectStorageKeys, + ObjectStorageType, ) @@ -191,3 +193,22 @@ def test_get_buckets_in_cluster( ): cluster = test_linode_client.load(ObjectStorageCluster, bucket.cluster) assert any(bucket.id == b.id for b in cluster.buckets_in_cluster()) + + +def test_object_storage_types(test_linode_client): + types = test_linode_client.object_storage.types() + + if len(types) > 0: + for object_storage_type in types: + assert type(object_storage_type) is ObjectStorageType + assert object_storage_type.price.monthly is None or ( + isinstance(object_storage_type.price.monthly, (float, int)) + and object_storage_type.price.monthly >= 0 + ) + if len(object_storage_type.region_prices) > 0: + region_price = object_storage_type.region_prices[0] + assert type(region_price) is RegionPrice + assert object_storage_type.price.monthly is None or ( + isinstance(object_storage_type.price.monthly, (float, int)) + and object_storage_type.price.monthly >= 0 + ) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 41cb9100d..c79c0a88d 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -980,6 +980,21 @@ def test_get_keys(self): self.assertEqual(key2.access_key, "testAccessKeyHere456") self.assertEqual(key2.secret_key, "[REDACTED]") + def test_object_storage_types(self): + """ + Tests that a list of ObjectStorageTypes can be retrieved + """ + types = self.client.object_storage.types() + self.assertEqual(len(types), 1) + self.assertEqual(types[0].id, "objectstorage") + self.assertEqual(types[0].label, "Object Storage") + self.assertEqual(types[0].price.hourly, 0.0015) + self.assertEqual(types[0].price.monthly, 0.1) + self.assertEqual(types[0].region_prices[0].id, "us-east") + self.assertEqual(types[0].region_prices[0].hourly, 0.00018) + self.assertEqual(types[0].region_prices[0].monthly, 0.12) + self.assertEqual(types[0].transfer, 0) + def test_keys_create(self): """ Tests that you can create Object Storage Keys From 70169d75964549d134d417b57b83706d8bb86682 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 11 Mar 2025 10:23:43 -0400 Subject: [PATCH 292/379] Added support for firewall rule version endpoints (#520) * Added support for firewall rule version endpoints * Updated get_rule_versions to be a property method --- linode_api4/objects/networking.py | 31 +++++++++++ .../networking_firewalls_123_history.json | 21 ++++++++ ...working_firewalls_123_history_rules_2.json | 24 +++++++++ .../models/networking/test_networking.py | 54 +++++++++++++++++++ test/unit/objects/networking_test.py | 48 +++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 test/fixtures/networking_firewalls_123_history.json create mode 100644 test/fixtures/networking_firewalls_123_history_rules_2.json diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 25130a919..b7a16ae90 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -244,6 +244,37 @@ def get_rules(self): "{}/rules".format(self.api_endpoint), model=self ) + @property + def rule_versions(self): + """ + Gets the JSON rule versions for this Firewall. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-rule-versions + + :returns: Lists the current and historical rules of the firewall (that is not deleted), + using version. Whenever the rules update, the version increments from 1. + :rtype: dict + """ + return self._client.get( + "{}/history".format(self.api_endpoint), model=self + ) + + def get_rule_version(self, version): + """ + Gets the JSON for a specific rule version for this Firewall. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-rule-version + + :param version: The firewall rule version to view. + :type version: int + + :returns: Gets a specific firewall rule version for an enabled or disabled firewall. + :rtype: dict + """ + return self._client.get( + "{}/history/rules/{}".format(self.api_endpoint, version), model=self + ) + def device_create(self, id, type="linode", **kwargs): """ Creates and attaches a device to this Firewall diff --git a/test/fixtures/networking_firewalls_123_history.json b/test/fixtures/networking_firewalls_123_history.json new file mode 100644 index 000000000..13f2b0df7 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_history.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "updated": "2025-03-07T17:06:36", + "status": "enabled", + "rules": { + "version": 1 + } + }, + { + "updated": "2025-03-07T17:06:36", + "status": "enabled", + "rules": { + "version": 2 + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/networking_firewalls_123_history_rules_2.json b/test/fixtures/networking_firewalls_123_history_rules_2.json new file mode 100644 index 000000000..3819436f8 --- /dev/null +++ b/test/fixtures/networking_firewalls_123_history_rules_2.json @@ -0,0 +1,24 @@ +{ + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "0.0.0.0/0" + ], + "ipv6": [ + "ff00::/8" + ] + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP" + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + "version": 2, + "fingerprint": "96c9568c" +} diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 032436246..b92cdfadc 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -1,3 +1,4 @@ +import time from test.integration.conftest import ( get_api_ca_file, get_api_url, @@ -72,6 +73,59 @@ def test_get_networking_rules(test_linode_client, test_firewall): assert "outbound_policy" in str(rules) +def test_get_networking_rule_versions(test_linode_client, test_firewall): + firewall = test_linode_client.load(Firewall, test_firewall.id) + + # Update the firewall's rules + new_rules = { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": ["0.0.0.0/0"], + "ipv6": ["ff00::/8"], + }, + "description": "A really cool firewall rule.", + "label": "really-cool-firewall-rule", + "ports": "80", + "protocol": "TCP", + } + ], + "inbound_policy": "ACCEPT", + "outbound": [], + "outbound_policy": "DROP", + } + firewall.update_rules(new_rules) + time.sleep(1) + + rule_versions = firewall.rule_versions + + # Original firewall rules + old_rule_version = firewall.get_rule_version(1) + + # Updated firewall rules + new_rule_version = firewall.get_rule_version(2) + + assert "rules" in str(rule_versions) + assert "version" in str(rule_versions) + assert rule_versions["results"] == 2 + + assert old_rule_version["inbound"] == [] + assert old_rule_version["inbound_policy"] == "ACCEPT" + assert old_rule_version["outbound"] == [] + assert old_rule_version["outbound_policy"] == "DROP" + assert old_rule_version["version"] == 1 + + assert ( + new_rule_version["inbound"][0]["description"] + == "A really cool firewall rule." + ) + assert new_rule_version["inbound_policy"] == "ACCEPT" + assert new_rule_version["outbound"] == [] + assert new_rule_version["outbound_policy"] == "DROP" + assert new_rule_version["version"] == 2 + + @pytest.mark.smoke def test_ip_addresses_share( test_linode_client, diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index d12167d8c..f982dd6f7 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -47,6 +47,54 @@ def test_get_rules(self): self.assertEqual(result["inbound_policy"], "DROP") self.assertEqual(result["outbound_policy"], "DROP") + def test_get_rule_versions(self): + """ + Tests that you can submit a correct firewall rule versions view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/history") as m: + result = firewall.rule_versions + self.assertEqual(m.call_url, "/networking/firewalls/123/history") + self.assertEqual(result["data"][0]["status"], "enabled") + self.assertEqual(result["data"][0]["rules"]["version"], 1) + self.assertEqual(result["data"][0]["status"], "enabled") + self.assertEqual(result["data"][1]["rules"]["version"], 2) + + def test_get_rule_version(self): + """ + Tests that you can submit a correct firewall rule version view api request. + """ + + firewall = Firewall(self.client, 123) + + with self.mock_get("/networking/firewalls/123/history/rules/2") as m: + result = firewall.get_rule_version(2) + self.assertEqual( + m.call_url, "/networking/firewalls/123/history/rules/2" + ) + self.assertEqual(result["inbound"][0]["action"], "ACCEPT") + self.assertEqual( + result["inbound"][0]["addresses"]["ipv4"][0], "0.0.0.0/0" + ) + self.assertEqual( + result["inbound"][0]["addresses"]["ipv6"][0], "ff00::/8" + ) + self.assertEqual( + result["inbound"][0]["description"], + "A really cool firewall rule.", + ) + self.assertEqual( + result["inbound"][0]["label"], "really-cool-firewall-rule" + ) + self.assertEqual(result["inbound"][0]["ports"], "80") + self.assertEqual(result["inbound"][0]["protocol"], "TCP") + self.assertEqual(result["outbound"], []) + self.assertEqual(result["inbound_policy"], "ACCEPT") + self.assertEqual(result["outbound_policy"], "DROP") + self.assertEqual(result["version"], 2) + def test_rdns_reset(self): """ Tests that the RDNS of an IP and be reset using an explicit null value. From 3692712d7a1b601a49f1f106330582acfca0e36a Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 20 Mar 2025 14:14:53 -0400 Subject: [PATCH 293/379] Add support to suspend and resume database (#519) * suspend and resume db * address comment --- linode_api4/objects/database.py | 48 +++++++++++++ .../databases_mysql_instances_123_resume.json | 1 + ...databases_mysql_instances_123_suspend.json | 1 + ...bases_postgresql_instances_123_resume.json | 1 + ...ases_postgresql_instances_123_suspend.json | 1 + .../models/database/test_database.py | 68 +++++++++++++++++++ test/unit/objects/database_test.py | 56 +++++++++++++++ 7 files changed, 176 insertions(+) create mode 100644 test/fixtures/databases_mysql_instances_123_resume.json create mode 100644 test/fixtures/databases_mysql_instances_123_suspend.json create mode 100644 test/fixtures/databases_postgresql_instances_123_resume.json create mode 100644 test/fixtures/databases_postgresql_instances_123_suspend.json diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 58044edb0..dc9db8471 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -265,6 +265,30 @@ def invalidate(self): Base.invalidate(self) + def suspend(self): + """ + Suspend a MySQL Managed Database, releasing idle resources and keeping only necessary data. + + API documentation: https://techdocs.akamai.com/linode-api/reference/suspend-databases-mysql-instance + """ + self._client.post( + "{}/suspend".format(MySQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + + def resume(self): + """ + Resume a suspended MySQL Managed Database. + + API documentation: https://techdocs.akamai.com/linode-api/reference/resume-databases-mysql-instance + """ + self._client.post( + "{}/resume".format(MySQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + class PostgreSQLDatabase(Base): """ @@ -405,6 +429,30 @@ def invalidate(self): Base.invalidate(self) + def suspend(self): + """ + Suspend a PostgreSQL Managed Database, releasing idle resources and keeping only necessary data. + + API documentation: https://techdocs.akamai.com/linode-api/reference/suspend-databases-postgre-sql-instance + """ + self._client.post( + "{}/suspend".format(PostgreSQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + + def resume(self): + """ + Resume a suspended PostgreSQL Managed Database. + + API documentation: https://techdocs.akamai.com/linode-api/reference/resume-databases-postgre-sql-instance + """ + self._client.post( + "{}/resume".format(PostgreSQLDatabase.api_endpoint), model=self + ) + + return self.invalidate() + ENGINE_TYPE_TRANSLATION = { "mysql": MySQLDatabase, diff --git a/test/fixtures/databases_mysql_instances_123_resume.json b/test/fixtures/databases_mysql_instances_123_resume.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_resume.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_suspend.json b/test/fixtures/databases_mysql_instances_123_suspend.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_mysql_instances_123_suspend.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_resume.json b/test/fixtures/databases_postgresql_instances_123_resume.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_resume.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_suspend.json b/test/fixtures/databases_postgresql_instances_123_suspend.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/test/fixtures/databases_postgresql_instances_123_suspend.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index 1ad5dde3b..351c09c2a 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -165,6 +165,40 @@ def test_database_instance(test_linode_client, test_create_sql_db): assert str(test_create_sql_db.id) in str(dbs.lists) +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_mysql_suspend_resume(test_linode_client, test_create_sql_db): + db = test_linode_client.load(MySQLDatabase, test_create_sql_db.id) + + db.suspend() + + wait_for_condition( + 10, + 300, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "suspended", + ) + + assert db.status == "suspended" + + db.resume() + + wait_for_condition( + 30, + 600, + get_sql_db_status, + test_linode_client, + test_create_sql_db.id, + "active", + ) + + assert db.status == "active" + + # ------- POSTGRESQL DB Test cases ------- @pytest.mark.skipif( os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, @@ -411,3 +445,37 @@ def test_reset_postgres_credentials( assert db.credentials.username == "akmadmin" assert db.credentials.password != old_pass + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_postgres_suspend_resume(test_linode_client, test_create_postgres_db): + db = test_linode_client.load(PostgreSQLDatabase, test_create_postgres_db.id) + + db.suspend() + + wait_for_condition( + 10, + 300, + get_postgres_db_status, + test_linode_client, + test_create_postgres_db.id, + "suspended", + ) + + assert db.status == "suspended" + + db.resume() + + wait_for_condition( + 30, + 600, + get_postgres_db_status, + test_linode_client, + test_create_postgres_db.id, + "active", + ) + + assert db.status == "active" diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 11b2379aa..51c7de4cd 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -263,6 +263,34 @@ def test_reset_credentials(self): m.call_url, "/databases/mysql/instances/123/credentials/reset" ) + def test_suspend(self): + """ + Test MySQL Database suspend logic. + """ + with self.mock_post("/databases/mysql/instances/123/suspend") as m: + db = MySQLDatabase(self.client, 123) + + db.suspend() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/suspend" + ) + + def test_resume(self): + """ + Test MySQL Database resume logic. + """ + with self.mock_post("/databases/mysql/instances/123/resume") as m: + db = MySQLDatabase(self.client, 123) + + db.resume() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/mysql/instances/123/resume" + ) + class PostgreSQLDatabaseTest(ClientBaseCase): """ @@ -451,3 +479,31 @@ def test_reset_credentials(self): m.call_url, "/databases/postgresql/instances/123/credentials/reset", ) + + def test_suspend(self): + """ + Test PostgreSQL Database suspend logic. + """ + with self.mock_post("/databases/postgresql/instances/123/suspend") as m: + db = PostgreSQLDatabase(self.client, 123) + + db.suspend() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/suspend" + ) + + def test_resume(self): + """ + Test PostgreSQL Database resume logic. + """ + with self.mock_post("/databases/postgresql/instances/123/resume") as m: + db = PostgreSQLDatabase(self.client, 123) + + db.resume() + + self.assertEqual(m.method, "post") + self.assertEqual( + m.call_url, "/databases/postgresql/instances/123/resume" + ) From 95970e4ac3368742ac3d5677695d4123ce69587e Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:52:26 -0400 Subject: [PATCH 294/379] Implement Support for LKE Enterprise (#521) * Implement support for LKE enterprise * Correct test --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/lke.py | 26 +++++- linode_api4/groups/lke_tier.py | 40 ++++++++++ linode_api4/linode_client.py | 17 +++- linode_api4/objects/lke.py | 50 +++++++++++- test/fixtures/lke_clusters_18881.json | 1 + test/fixtures/lke_clusters_18882.json | 14 ++++ .../lke_clusters_18882_pools_789.json | 18 +++++ .../fixtures/lke_tiers_standard_versions.json | 19 +++++ test/integration/models/lke/test_lke.py | 79 +++++++++++++++++++ test/unit/groups/lke_tier_test.py | 18 +++++ test/unit/objects/lke_test.py | 22 +++++- 12 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 linode_api4/groups/lke_tier.py create mode 100644 test/fixtures/lke_clusters_18882.json create mode 100644 test/fixtures/lke_clusters_18882_pools_789.json create mode 100644 test/fixtures/lke_tiers_standard_versions.json create mode 100644 test/unit/groups/lke_tier_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index db08d8939..e50eeab66 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -8,6 +8,7 @@ from .image import * from .linode import * from .lke import * +from .lke_tier import * from .longview import * from .networking import * from .nodebalancer import * diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index 4d13bb650..c3d6fdc5d 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -1,7 +1,8 @@ -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group +from linode_api4.groups.lke_tier import LKETierGroup from linode_api4.objects import ( KubeVersion, LKECluster, @@ -67,6 +68,7 @@ def cluster_create( LKEClusterControlPlaneOptions, Dict[str, Any] ] = None, apl_enabled: bool = False, + tier: Optional[str] = None, **kwargs, ): """ @@ -104,9 +106,13 @@ def cluster_create( :param control_plane: The control plane configuration of this LKE cluster. :type control_plane: Dict[str, Any] or LKEClusterControlPlaneRequest :param apl_enabled: Whether this cluster should use APL. - NOTE: This endpoint is in beta and may only + NOTE: This field is in beta and may only function if base_url is set to `https://api.linode.com/v4beta`. :type apl_enabled: bool + :param tier: The tier of LKE cluster to create. + NOTE: This field is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type tier: str :param kwargs: Any other arguments to pass along to the API. See the API docs for possible values. @@ -122,6 +128,7 @@ def cluster_create( node_pools if isinstance(node_pools, list) else [node_pools] ), "control_plane": control_plane, + "tier": tier, } params.update(kwargs) @@ -183,3 +190,18 @@ def types(self, *filters): return self.client._get_and_filter( LKEType, *filters, endpoint="/lke/types" ) + + def tier(self, id: str) -> LKETierGroup: + """ + Returns an object representing the LKE tier API path. + + NOTE: LKE tiers may not currently be available to all users. + + :param id: The ID of the tier. + :type id: str + + :returns: An object representing the LKE tier API path. + :rtype: LKETier + """ + + return LKETierGroup(self.client, id) diff --git a/linode_api4/groups/lke_tier.py b/linode_api4/groups/lke_tier.py new file mode 100644 index 000000000..e5b8d11e5 --- /dev/null +++ b/linode_api4/groups/lke_tier.py @@ -0,0 +1,40 @@ +from linode_api4.groups import Group +from linode_api4.objects import TieredKubeVersion + + +class LKETierGroup(Group): + """ + Encapsulates methods related to a specific LKE tier. This + should not be instantiated on its own, but should instead be used through + an instance of :any:`LinodeClient`:: + + client = LinodeClient(token) + instances = client.lke.tier("standard") # use the LKETierGroup + + This group contains all features beneath the `/lke/tiers/{tier}` group in the API v4. + """ + + def __init__(self, client: "LinodeClient", tier: str): + super().__init__(client) + self.tier = tier + + def versions(self, *filters): + """ + Returns a paginated list of versions for this tier matching the given filters. + + API Documentation: Not Yet Available + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A paginated list of kube versions that match the query. + :rtype: PaginatedList of TieredKubeVersion + """ + + return self.client._get_and_filter( + TieredKubeVersion, + endpoint=f"/lke/tiers/{self.tier}/versions", + parent_id=self.tier, + *filters, + ) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index dbb45d0df..19e6f3900 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -455,7 +455,13 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): ) # helper functions - def _get_and_filter(self, obj_type, *filters, endpoint=None): + def _get_and_filter( + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, + ): parsed_filters = None if filters: if len(filters) > 1: @@ -467,8 +473,13 @@ def _get_and_filter(self, obj_type, *filters, endpoint=None): # Use sepcified endpoint if endpoint: - return self._get_objects(endpoint, obj_type, filters=parsed_filters) + return self._get_objects( + endpoint, obj_type, parent_id=parent_id, filters=parsed_filters + ) else: return self._get_objects( - obj_type.api_list(), obj_type, filters=parsed_filters + obj_type.api_list(), + obj_type, + parent_id=parent_id, + filters=parsed_filters, ) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 2f670f2b9..7086b1113 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -14,6 +14,7 @@ Region, Type, ) +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.util import drop_null_keys @@ -49,6 +50,26 @@ class KubeVersion(Base): } +class TieredKubeVersion(DerivedBase): + """ + A TieredKubeVersion is a version of Kubernetes that is specific to a certain LKE tier. + + NOTE: LKE tiers may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-version + """ + + api_endpoint = "/lke/tiers/{tier}/versions/{id}" + parent_id_name = "tier" + id_attribute = "id" + derived_url_path = "versions" + + properties = { + "id": Property(identifier=True), + "tier": Property(identifier=True), + } + + @dataclass class LKENodePoolTaint(JSONObject): """ @@ -154,6 +175,8 @@ class LKENodePool(DerivedBase): An LKE Node Pool describes a pool of Linode Instances that exist within an LKE Cluster. + NOTE: The k8s_version and update_strategy fields are only available for LKE Enterprise clusters. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-node-pool """ @@ -175,6 +198,12 @@ class LKENodePool(DerivedBase): "tags": Property(mutable=True, unordered=True), "labels": Property(mutable=True), "taints": Property(mutable=True), + # Enterprise-specific properties + # Ideally we would use slug_relationship=TieredKubeVersion here, but + # it isn't possible without an extra request because the tier is not + # directly exposed in the node pool response. + "k8s_version": Property(mutable=True), + "update_strategy": Property(mutable=True), } def _parse_raw_node( @@ -255,6 +284,7 @@ class LKECluster(Base): "pools": Property(derived_class=LKENodePool), "control_plane": Property(mutable=True), "apl_enabled": Property(), + "tier": Property(), } def invalidate(self): @@ -385,6 +415,10 @@ def node_pool_create( node_count: int, labels: Optional[Dict[str, str]] = None, taints: List[Union[LKENodePoolTaint, Dict[str, Any]]] = None, + k8s_version: Optional[ + Union[str, KubeVersion, TieredKubeVersion] + ] = None, + update_strategy: Optional[str] = None, **kwargs, ): """ @@ -399,7 +433,13 @@ def node_pool_create( :param labels: A dict mapping labels to their values to apply to this pool. :type labels: Dict[str, str] :param taints: A list of taints to apply to this pool. - :type taints: List of :any:`LKENodePoolTaint` or dict + :type taints: List of :any:`LKENodePoolTaint` or dict. + :param k8s_version: The Kubernetes version to use for this pool. + NOTE: This field is specific to enterprise clusters. + :type k8s_version: str, KubeVersion, or TieredKubeVersion + :param update_strategy: The strategy to use when updating this node pool. + NOTE: This field is specific to enterprise clusters. + :type update_strategy: str :param kwargs: Any other arguments to pass to the API. See the API docs for possible values. @@ -409,6 +449,10 @@ def node_pool_create( params = { "type": node_type, "count": node_count, + "labels": labels, + "taints": taints, + "k8s_version": k8s_version, + "update_strategy": update_strategy, } if labels is not None: @@ -420,7 +464,9 @@ def node_pool_create( params.update(kwargs) result = self._client.post( - "{}/pools".format(LKECluster.api_endpoint), model=self, data=params + "{}/pools".format(LKECluster.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), ) self.invalidate() diff --git a/test/fixtures/lke_clusters_18881.json b/test/fixtures/lke_clusters_18881.json index bb5807c18..a520e49ea 100644 --- a/test/fixtures/lke_clusters_18881.json +++ b/test/fixtures/lke_clusters_18881.json @@ -6,6 +6,7 @@ "label": "example-cluster", "region": "ap-west", "k8s_version": "1.19", + "tier": "standard", "tags": [], "control_plane": { "high_availability": true diff --git a/test/fixtures/lke_clusters_18882.json b/test/fixtures/lke_clusters_18882.json new file mode 100644 index 000000000..49548c018 --- /dev/null +++ b/test/fixtures/lke_clusters_18882.json @@ -0,0 +1,14 @@ +{ + "id": 18881, + "status": "ready", + "created": "2021-02-10T23:54:21", + "updated": "2021-02-10T23:54:21", + "label": "example-cluster-2", + "region": "ap-west", + "k8s_version": "1.31.1+lke1", + "tier": "enterprise", + "tags": [], + "control_plane": { + "high_availability": true + } +} \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18882_pools_789.json b/test/fixtures/lke_clusters_18882_pools_789.json new file mode 100644 index 000000000..a7bbc4749 --- /dev/null +++ b/test/fixtures/lke_clusters_18882_pools_789.json @@ -0,0 +1,18 @@ +{ + "id": 789, + "type": "g6-standard-2", + "count": 3, + "nodes": [], + "disks": [], + "autoscaler": { + "enabled": false, + "min": 3, + "max": 3 + }, + "labels": {}, + "taints": [], + "tags": [], + "disk_encryption": "enabled", + "k8s_version": "1.31.1+lke1", + "update_strategy": "rolling_update" +} \ No newline at end of file diff --git a/test/fixtures/lke_tiers_standard_versions.json b/test/fixtures/lke_tiers_standard_versions.json new file mode 100644 index 000000000..5dfeeb4ab --- /dev/null +++ b/test/fixtures/lke_tiers_standard_versions.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "id": "1.32", + "tier": "standard" + }, + { + "id": "1.31", + "tier": "standard" + }, + { + "id": "1.30", + "tier": "standard" + } + ], + "page": 1, + "pages": 1, + "results": 3 +} diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 794bc3203..e4c941c16 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -14,6 +14,7 @@ LKEClusterControlPlaneACLAddressesOptions, LKEClusterControlPlaneACLOptions, LKEClusterControlPlaneOptions, + TieredKubeVersion, ) from linode_api4.common import RegionPrice from linode_api4.errors import ApiError @@ -136,6 +137,38 @@ def lke_cluster_with_apl(test_linode_client): cluster.delete() +@pytest.fixture(scope="session") +def lke_cluster_enterprise(test_linode_client): + # We use the oldest version here so we can test upgrades + version = sorted( + v.id for v in test_linode_client.lke.tier("enterprise").versions() + )[0] + + region = get_region( + test_linode_client, {"Kubernetes Enterprise", "Disk Encryption"} + ) + + node_pools = test_linode_client.lke.node_pool( + "g6-dedicated-2", + 3, + k8s_version=version, + update_strategy="rolling_update", + ) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, + label, + node_pools, + version, + tier="enterprise", + ) + + yield cluster + + cluster.delete() + + def get_cluster_status(cluster: LKECluster, status: str): return cluster._raw_json["status"] == status @@ -398,6 +431,52 @@ def test_lke_cluster_with_apl(lke_cluster_with_apl): ) +def test_lke_cluster_enterprise(test_linode_client, lke_cluster_enterprise): + lke_cluster_enterprise.invalidate() + assert lke_cluster_enterprise.tier == "enterprise" + + pool = lke_cluster_enterprise.pools[0] + assert str(pool.k8s_version) == lke_cluster_enterprise.k8s_version.id + assert pool.update_strategy == "rolling_update" + + target_version = sorted( + v.id for v in test_linode_client.lke.tier("enterprise").versions() + )[0] + pool.update_strategy = "on_recycle" + pool.k8s_version = target_version + + pool.save() + + pool.invalidate() + + assert pool.k8s_version == target_version + assert pool.update_strategy == "on_recycle" + + +def test_lke_tiered_versions(test_linode_client): + def __assert_version(tier: str, version: TieredKubeVersion): + assert version.tier == tier + assert len(version.id) > 0 + + standard_versions = test_linode_client.lke.tier("standard").versions() + assert len(standard_versions) > 0 + + standard_version = standard_versions[0] + __assert_version("standard", standard_version) + + standard_version.invalidate() + __assert_version("standard", standard_version) + + enterprise_versions = test_linode_client.lke.tier("enterprise").versions() + assert len(enterprise_versions) > 0 + + enterprise_version = enterprise_versions[0] + __assert_version("enterprise", enterprise_version) + + enterprise_version.invalidate() + __assert_version("enterprise", enterprise_version) + + def test_lke_types(test_linode_client): types = test_linode_client.lke.types() diff --git a/test/unit/groups/lke_tier_test.py b/test/unit/groups/lke_tier_test.py new file mode 100644 index 000000000..de4ae5212 --- /dev/null +++ b/test/unit/groups/lke_tier_test.py @@ -0,0 +1,18 @@ +from test.unit.base import ClientBaseCase + + +class LKETierGroupTest(ClientBaseCase): + """ + Tests methods under the LKETierGroup class. + """ + + def test_list_versions(self): + """ + Tests that LKE versions can be listed for a given tier. + """ + + tiers = self.client.lke.tier("standard").versions() + + assert tiers[0].id == "1.32" + assert tiers[1].id == "1.31" + assert tiers[2].id == "1.30" diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 1f397afac..a0ad63288 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -2,7 +2,7 @@ from test.unit.base import ClientBaseCase from unittest.mock import MagicMock -from linode_api4 import InstanceDiskEncryptionType +from linode_api4 import InstanceDiskEncryptionType, TieredKubeVersion from linode_api4.objects import ( LKECluster, LKEClusterControlPlaneACLAddressesOptions, @@ -536,3 +536,23 @@ def test_cluster_update_acl_null_addresses(self): # Addresses should not be included in the API request if it's null # See: TPT-3489 assert m.call_data == {"acl": {"enabled": True}} + + def test_cluster_enterprise(self): + cluster = LKECluster(self.client, 18882) + + assert cluster.tier == "enterprise" + assert cluster.k8s_version.id == "1.31.1+lke1" + + pool = LKENodePool(self.client, 789, 18882) + assert pool.k8s_version == "1.31.1+lke1" + assert pool.update_strategy == "rolling_update" + + def test_lke_tiered_version(self): + version = TieredKubeVersion(self.client, "1.32", "standard") + + assert version.id == "1.32" + + # Ensure the version is properly refreshed + version.invalidate() + + assert version.id == "1.32" From cf04ca67e5d9ef732ac298dc05f14bbd5b841c7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 09:18:02 -0400 Subject: [PATCH 295/379] build(deps): bump crazy-max/ghaction-github-labeler from 5.2.0 to 5.3.0 (#523) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.2.0 to 5.3.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/31674a3852a9074f2086abcf1c53839d466a47e7...24d110aa46a59976b8a7f35518cb7f14f434c916) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8a9bcadd2..30bcb1956 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@31674a3852a9074f2086abcf1c53839d466a47e7 + uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml From 0ecf79a1ba881ad8434ea075d8f1f853b7d735e7 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 15 Apr 2025 07:26:38 -0700 Subject: [PATCH 296/379] Update test region capability for LDE (#530) * update submodule * update capability in test region and miscellaneous test fixes * remove error fixture and add retry * lint * pr comments * pr comments --- test/integration/login_client/test_login_client.py | 1 + test/integration/models/domain/test_domain.py | 2 -- test/integration/models/linode/test_linode.py | 12 ++++++------ test/integration/models/lke/test_lke.py | 12 ++++++++---- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/test/integration/login_client/test_login_client.py b/test/integration/login_client/test_login_client.py index ccbeb1976..24519346c 100644 --- a/test/integration/login_client/test_login_client.py +++ b/test/integration/login_client/test_login_client.py @@ -97,6 +97,7 @@ def test_linode_login_client_generate_login_url_with_scope(linode_login_client): assert "scopes=linodes%3Aread_write" in url +@pytest.mark.skip("Endpoint may be deprecated") def test_linode_login_client_expire_token( linode_login_client, test_oauth_client ): diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index 36ecbb0dc..9dc180a6e 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -23,8 +23,6 @@ def test_save_null_values_excluded(test_linode_client, test_domain): domain.master_ips = ["127.0.0.1"] res = domain.save() - assert res - def test_zone_file_view(test_linode_client, test_domain): domain = test_linode_client.load(Domain, test_domain.id) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index d97a8294a..835330810 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -9,7 +9,6 @@ import pytest -from linode_api4 import VPCIPAddress from linode_api4.errors import ApiError from linode_api4.objects import ( Config, @@ -181,7 +180,7 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): def linode_with_disk_encryption(test_linode_client, request): client = test_linode_client - target_region = get_region(client, {"Disk Encryption"}) + target_region = get_region(client, {"LA Disk Encryption"}) label = get_test_label(length=8) disk_encryption = request.param @@ -236,7 +235,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - region = get_region(client, {"Disk Encryption"}) + region = get_region(client, {"LA Disk Encryption"}) label = get_test_label() + "_rebuild" @@ -535,6 +534,7 @@ def test_linode_create_disk(test_linode_client, linode_for_disk_tests): assert disk.linode_id == linode.id +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_instance_password(create_linode_for_pass_reset): linode = create_linode_for_pass_reset[0] password = create_linode_for_pass_reset[1] @@ -775,10 +775,10 @@ def test_create_vpc( assert vpc_range_ip.address_range == "10.0.0.5/32" assert not vpc_range_ip.active + # TODO:: Add `VPCIPAddress.filters.linode_id == linode.id` filter back + # Attempt to resolve the IP from /vpcs/ips - all_vpc_ips = test_linode_client.vpcs.ips( - VPCIPAddress.filters.linode_id == linode.id - ) + all_vpc_ips = test_linode_client.vpcs.ips() assert all_vpc_ips[0].dict == vpc_ip.dict # Test getting the ips under this specific VPC diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index e4c941c16..e0a9eafb1 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -32,7 +32,9 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) + region = get_region( + test_linode_client, {"Kubernetes", "LA Disk Encryption"} + ) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -115,7 +117,9 @@ def lke_cluster_with_labels_and_taints(test_linode_client): def lke_cluster_with_apl(test_linode_client): version = test_linode_client.lke.versions()[0] - region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) + region = get_region( + test_linode_client, {"Kubernetes", "LA Disk Encryption"} + ) # NOTE: g6-dedicated-4 is the minimum APL-compatible Linode type node_pools = test_linode_client.lke.node_pool("g6-dedicated-4", 3) @@ -145,7 +149,7 @@ def lke_cluster_enterprise(test_linode_client): )[0] region = get_region( - test_linode_client, {"Kubernetes Enterprise", "Disk Encryption"} + test_linode_client, {"Kubernetes Enterprise", "LA Disk Encryption"} ) node_pools = test_linode_client.lke.node_pool( @@ -204,7 +208,7 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) - assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + assert pool.disk_encryption == InstanceDiskEncryptionType.disabled def test_cluster_dashboard_url_view(lke_cluster): From b2eff93cbe7b023c7fc35fdd6d0c73bc6866189c Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 28 Apr 2025 11:33:58 -0400 Subject: [PATCH 297/379] Implement JSONObject put_class ClassVar (#534) --- linode_api4/objects/account.py | 4 +- linode_api4/objects/base.py | 26 +++++++----- linode_api4/objects/linode.py | 12 +++--- linode_api4/objects/serializable.py | 24 +++++++++-- test/unit/objects/serializable_test.py | 55 +++++++++++++++++++++++++- 5 files changed, 99 insertions(+), 22 deletions(-) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 375e5fc03..c7318d871 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -601,7 +601,7 @@ def entity(self): ) return self.cls(self._client, self.id) - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns this grant in as JSON the api will accept. This is only relevant in the context of UserGrants.save @@ -668,7 +668,7 @@ def _grants_dict(self): return grants - def _serialize(self): + def _serialize(self, *args, **kwargs): """ Returns the user grants in as JSON the api will accept. This is only relevant in the context of UserGrants.save diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 6c9b1bece..c9a622edc 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -114,6 +114,9 @@ def _flatten_base_subclass(obj: "Base") -> Optional[Dict[str, Any]]: @property def dict(self): + return self._serialize() + + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: result = vars(self).copy() cls = type(self) @@ -123,7 +126,7 @@ def dict(self): elif isinstance(v, list): result[k] = [ ( - item.dict + item._serialize(is_put=is_put) if isinstance(item, (cls, JSONObject)) else ( self._flatten_base_subclass(item) @@ -136,7 +139,7 @@ def dict(self): elif isinstance(v, Base): result[k] = self._flatten_base_subclass(v) elif isinstance(v, JSONObject): - result[k] = v.dict + result[k] = v._serialize(is_put=is_put) return result @@ -278,9 +281,9 @@ def save(self, force=True) -> bool: data[key] = None # Ensure we serialize any values that may not be already serialized - data = _flatten_request_body_recursive(data) + data = _flatten_request_body_recursive(data, is_put=True) else: - data = self._serialize() + data = self._serialize(is_put=True) resp = self._client.put(type(self).api_endpoint, model=self, data=data) @@ -316,7 +319,7 @@ def invalidate(self): self._set("_populated", False) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ A helper method to build a dict of all mutable Properties of this object @@ -345,7 +348,7 @@ def _serialize(self): # Resolve the underlying IDs of results for k, v in result.items(): - result[k] = _flatten_request_body_recursive(v) + result[k] = _flatten_request_body_recursive(v, is_put=is_put) return result @@ -503,7 +506,7 @@ def make_instance(cls, id, client, parent_id=None, json=None): return Base.make(id, client, cls, parent_id=parent_id, json=json) -def _flatten_request_body_recursive(data: Any) -> Any: +def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any: """ This is a helper recursively flatten the given data for use in an API request body. @@ -515,15 +518,18 @@ def _flatten_request_body_recursive(data: Any) -> Any: """ if isinstance(data, dict): - return {k: _flatten_request_body_recursive(v) for k, v in data.items()} + return { + k: _flatten_request_body_recursive(v, is_put=is_put) + for k, v in data.items() + } if isinstance(data, list): - return [_flatten_request_body_recursive(v) for v in data] + return [_flatten_request_body_recursive(v, is_put=is_put) for v in data] if isinstance(data, Base): return data.id if isinstance(data, MappedObject) or issubclass(type(data), JSONObject): - return data.dict + return data._serialize(is_put=is_put) return data diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 46af5d970..c70dd7965 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -400,7 +400,7 @@ class ConfigInterface(JSONObject): def __repr__(self): return f"Interface: {self.purpose}" - def _serialize(self): + def _serialize(self, *args, **kwargs): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -510,16 +510,16 @@ def _populate(self, json): self._set("devices", MappedObject(**devices)) - def _serialize(self): + def _serialize(self, is_put: bool = False): """ Overrides _serialize to transform interfaces into json """ - partial = DerivedBase._serialize(self) + partial = DerivedBase._serialize(self, is_put=is_put) interfaces = [] for c in self.interfaces: if isinstance(c, ConfigInterface): - interfaces.append(c._serialize()) + interfaces.append(c._serialize(is_put=is_put)) else: interfaces.append(c) @@ -1927,8 +1927,8 @@ def _populate(self, json): ndist = [Image(self._client, d) for d in self.images] self._set("images", ndist) - def _serialize(self): - dct = Base._serialize(self) + def _serialize(self, is_put: bool = False): + dct = Base._serialize(self, is_put=is_put) dct["images"] = [d.id for d in self.images] return dct diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index fea682f43..e33179a60 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -1,5 +1,5 @@ import inspect -from dataclasses import dataclass +from dataclasses import dataclass, fields from enum import Enum from types import SimpleNamespace from typing import ( @@ -9,6 +9,7 @@ List, Optional, Set, + Type, Union, get_args, get_origin, @@ -71,6 +72,13 @@ class JSONObject(metaclass=JSONFilterableMetaclass): are None. """ + put_class: ClassVar[Optional[Type["JSONObject"]]] = None + """ + An alternative JSONObject class to use as the schema for PUT requests. + This prevents read-only fields from being included in PUT request bodies, + which in theory will result in validation errors from the API. + """ + def __init__(self): raise NotImplementedError( "JSONObject is not intended to be constructed directly" @@ -154,11 +162,17 @@ def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: return obj - def _serialize(self) -> Dict[str, Any]: + def _serialize(self, is_put: bool = False) -> Dict[str, Any]: """ Serializes this object into a JSON dict. """ cls = type(self) + + if is_put and cls.put_class is not None: + cls = cls.put_class + + cls_field_keys = {field.name for field in fields(cls)} + type_hints = get_type_hints(cls) def attempt_serialize(value: Any) -> Any: @@ -166,7 +180,7 @@ def attempt_serialize(value: Any) -> Any: Attempts to serialize the given value, else returns the value unchanged. """ if issubclass(type(value), JSONObject): - return value._serialize() + return value._serialize(is_put=is_put) return value @@ -175,6 +189,10 @@ def should_include(key: str, value: Any) -> bool: Returns whether the given key/value pair should be included in the resulting dict. """ + # During PUT operations, keys not present in the put_class should be excluded + if key not in cls_field_keys: + return False + if cls.include_none_values or key in cls.always_include: return True diff --git a/test/unit/objects/serializable_test.py b/test/unit/objects/serializable_test.py index a15f108b4..9a775ccf1 100644 --- a/test/unit/objects/serializable_test.py +++ b/test/unit/objects/serializable_test.py @@ -2,7 +2,7 @@ from test.unit.base import ClientBaseCase from typing import Optional -from linode_api4 import JSONObject +from linode_api4 import Base, JSONObject, Property class JSONObjectTest(ClientBaseCase): @@ -47,3 +47,56 @@ class Foo(JSONObject): assert foo["foo"] == "test" assert foo["bar"] == "test2" assert foo["baz"] == "test3" + + def test_serialize_put_class(self): + """ + Ensures that the JSONObject put_class ClassVar functions as expected. + """ + + @dataclass + class SubStructOptions(JSONObject): + test1: Optional[str] = None + + @dataclass + class SubStruct(JSONObject): + put_class = SubStructOptions + + test1: str = "" + test2: int = 0 + + class Model(Base): + api_endpoint = "/foo/bar" + + properties = { + "id": Property(identifier=True), + "substruct": Property(mutable=True, json_object=SubStruct), + } + + mock_response = { + "id": 123, + "substruct": { + "test1": "abc", + "test2": 321, + }, + } + + with self.mock_get(mock_response) as mock: + obj = self.client.load(Model, 123) + + assert mock.called + + assert obj.id == 123 + assert obj.substruct.test1 == "abc" + assert obj.substruct.test2 == 321 + + obj.substruct.test1 = "cba" + + with self.mock_put(mock_response) as mock: + obj.save() + + assert mock.called + assert mock.call_data == { + "substruct": { + "test1": "cba", + } + } From c72280ed7106e66e84a74c0b1510f4e58a77a54b Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:37:07 -0700 Subject: [PATCH 298/379] add retry to flaky test, safety around bucket delete (#538) --- test/integration/models/linode/test_linode.py | 1 + test/integration/models/object_storage/test_obj.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 835330810..ade4ca5ed 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -364,6 +364,7 @@ def test_linode_resize(create_linode_for_long_running_tests): assert linode.status == "running" +@pytest.mark.flaky(reruns=3, reruns_delay=2) def test_linode_resize_with_class( test_linode_client, create_linode_for_long_running_tests ): diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index 33ce8dfbe..e52f85e0f 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -1,5 +1,6 @@ import time from test.integration.conftest import get_region +from test.integration.helpers import send_request_when_resource_available import pytest @@ -38,7 +39,7 @@ def bucket( ) yield bucket - bucket.delete() + send_request_when_resource_available(timeout=100, func=bucket.delete) @pytest.fixture(scope="session") @@ -63,7 +64,8 @@ def bucket_with_endpoint( ) yield bucket - bucket.delete() + + send_request_when_resource_available(timeout=100, func=bucket.delete) @pytest.fixture(scope="session") From c449113cd05fc8885c8470df6541395547050915 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 29 Apr 2025 10:51:59 -0700 Subject: [PATCH 299/379] update obj test region (#539) --- test/integration/models/object_storage/test_obj.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/integration/models/object_storage/test_obj.py b/test/integration/models/object_storage/test_obj.py index e52f85e0f..047dfbdb4 100644 --- a/test/integration/models/object_storage/test_obj.py +++ b/test/integration/models/object_storage/test_obj.py @@ -1,5 +1,4 @@ import time -from test.integration.conftest import get_region from test.integration.helpers import send_request_when_resource_available import pytest @@ -19,7 +18,7 @@ @pytest.fixture(scope="session") def region(test_linode_client: LinodeClient): - return get_region(test_linode_client, {"Object Storage"}).id + return "us-southeast" # uncomment get_region(test_linode_client, {"Object Storage"}).id @pytest.fixture(scope="session") From 8cc03eea14a20ae02fe8c6d8b21ddbdccbd536a2 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 7 May 2025 03:19:38 -0400 Subject: [PATCH 300/379] Trusted publisher for PyPI (#536) --- .github/workflows/publish-pypi.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index d5338b7a7..027ac5298 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -5,7 +5,11 @@ on: types: [ published ] jobs: pypi-release: + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write runs-on: ubuntu-latest + environment: pypi-release steps: - name: Checkout uses: actions/checkout@v4 @@ -25,5 +29,3 @@ jobs: - name: Publish the release artifacts to PyPI uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # pin@release/v1.12.4 - with: - password: ${{ secrets.PYPI_API_TOKEN }} From 751180e2a70b363cc94a5c2382b5af518231ab8e Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 12 May 2025 12:20:55 -0400 Subject: [PATCH 301/379] Project: Limits Visibility M1 (#544) * Support Object Storage Quota Limits Visibility (#531) * obj quota * add comment * build json object * add obj quotas int tests (#535) * Update Object Storage quota doc link (#543) --------- Co-authored-by: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> --- linode_api4/groups/object_storage.py | 16 +++++ linode_api4/objects/object_storage.py | 49 ++++++++++++++++ test/fixtures/object-storage_quotas.json | 25 ++++++++ ...t-storage_quotas_obj-objects-us-ord-1.json | 9 +++ ...age_quotas_obj-objects-us-ord-1_usage.json | 4 ++ .../models/object_storage/test_obj_quotas.py | 58 +++++++++++++++++++ test/unit/objects/object_storage_test.py | 51 ++++++++++++++++ 7 files changed, 212 insertions(+) create mode 100644 test/fixtures/object-storage_quotas.json create mode 100644 test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json create mode 100644 test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json create mode 100644 test/integration/models/object_storage/test_obj_quotas.py diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index eb6a296b7..5ffab3ffc 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -21,6 +21,7 @@ ObjectStorageCluster, ObjectStorageKeyPermission, ObjectStorageKeys, + ObjectStorageQuota, ) from linode_api4.util import drop_null_keys @@ -517,3 +518,18 @@ def object_url_create( ) return MappedObject(**result) + + def quotas(self, *filters): + """ + Lists the active ObjectStorage-related quotas applied to your account. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quotas + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Object Storage Quotas that matched the query. + :rtype: PaginatedList of ObjectStorageQuota + """ + return self.client._get_and_filter(ObjectStorageQuota, *filters) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index be1fd0cc7..29eba2b06 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -51,6 +51,16 @@ class ObjectStorageEndpoint(JSONObject): s3_endpoint: Optional[str] = None +@dataclass +class ObjectStorageQuotaUsage(JSONObject): + """ + ObjectStorageQuotaUsage contains the fields of an object storage quota usage information. + """ + + quota_limit: int = 0 + usage: int = 0 + + class ObjectStorageType(Base): """ An ObjectStorageType represents the structure of a valid Object Storage type. @@ -566,3 +576,42 @@ class ObjectStorageKeys(Base): "limited": Property(), "regions": Property(unordered=True), } + + +class ObjectStorageQuota(Base): + """ + An Object Storage related quota information on your account. + Object Storage Quota related features are under v4beta and may not currently be available to all users. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quota + """ + + api_endpoint = "/object-storage/quotas/{quota_id}" + id_attribute = "quota_id" + + properties = { + "quota_id": Property(identifier=True), + "quota_name": Property(), + "endpoint_type": Property(), + "s3_endpoint": Property(), + "description": Property(), + "quota_limit": Property(), + "resource_metric": Property(), + } + + def usage(self): + """ + Gets usage data for a specific ObjectStorage Quota resource you can have on your account and the current usage for that resource. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quota-usage + + :returns: The Object Storage Quota usage. + :rtype: ObjectStorageQuotaUsage + """ + + result = self._client.get( + f"{type(self).api_endpoint}/usage", + model=self, + ) + + return ObjectStorageQuotaUsage.from_json(result) diff --git a/test/fixtures/object-storage_quotas.json b/test/fixtures/object-storage_quotas.json new file mode 100644 index 000000000..e831d7303 --- /dev/null +++ b/test/fixtures/object-storage_quotas.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "quota_id": "obj-objects-us-ord-1", + "quota_name": "Object Storage Maximum Objects", + "description": "Maximum number of Objects this customer is allowed to have on this endpoint.", + "endpoint_type": "E1", + "s3_endpoint": "us-iad-1.linodeobjects.com", + "quota_limit": 50, + "resource_metric": "object" + }, + { + "quota_id": "obj-bucket-us-ord-1", + "quota_name": "Object Storage Maximum Buckets", + "description": "Maximum number of buckets this customer is allowed to have on this endpoint.", + "endpoint_type": "E1", + "s3_endpoint": "us-iad-1.linodeobjects.com", + "quota_limit": 50, + "resource_metric": "bucket" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json new file mode 100644 index 000000000..e01d743c3 --- /dev/null +++ b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json @@ -0,0 +1,9 @@ +{ + "quota_id": "obj-objects-us-ord-1", + "quota_name": "Object Storage Maximum Objects", + "description": "Maximum number of Objects this customer is allowed to have on this endpoint.", + "endpoint_type": "E1", + "s3_endpoint": "us-iad-1.linodeobjects.com", + "quota_limit": 50, + "resource_metric": "object" +} \ No newline at end of file diff --git a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json new file mode 100644 index 000000000..59b306044 --- /dev/null +++ b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1_usage.json @@ -0,0 +1,4 @@ +{ + "quota_limit": 100, + "usage": 10 +} diff --git a/test/integration/models/object_storage/test_obj_quotas.py b/test/integration/models/object_storage/test_obj_quotas.py new file mode 100644 index 000000000..b1beade44 --- /dev/null +++ b/test/integration/models/object_storage/test_obj_quotas.py @@ -0,0 +1,58 @@ +from linode_api4.objects.object_storage import ( + ObjectStorageQuota, + ObjectStorageQuotaUsage, +) + + +def test_list_obj_storage_quotas(test_linode_client): + quotas = test_linode_client.object_storage.quotas() + + target_quota_id = "obj-buckets-us-sea-1.linodeobjects.com" + + found_quota = None + for quota in quotas: + if quota.quota_id == target_quota_id: + found_quota = quota + break + + assert ( + found_quota is not None + ), f"Quota with ID {target_quota_id} not found." + + assert found_quota.quota_id == "obj-buckets-us-sea-1.linodeobjects.com" + assert found_quota.quota_name == "max_buckets" + assert found_quota.endpoint_type == "E1" + assert found_quota.s3_endpoint == "us-sea-1.linodeobjects.com" + assert ( + found_quota.description + == "Maximum number of buckets this customer is allowed to have on this endpoint" + ) + assert found_quota.quota_limit == 1000 + assert found_quota.resource_metric == "bucket" + + +def test_get_obj_storage_quota(test_linode_client): + quota_id = "obj-objects-us-ord-1.linodeobjects.com" + quota = test_linode_client.load(ObjectStorageQuota, quota_id) + + assert quota.quota_id == "obj-objects-us-ord-1.linodeobjects.com" + assert quota.quota_name == "max_objects" + assert quota.endpoint_type == "E1" + assert quota.s3_endpoint == "us-ord-1.linodeobjects.com" + assert ( + quota.description + == "Maximum number of objects this customer is allowed to have on this endpoint" + ) + assert quota.quota_limit == 100000000 + assert quota.resource_metric == "object" + + +def test_get_obj_storage_quota_usage(test_linode_client): + quota_id = "obj-objects-us-ord-1.linodeobjects.com" + quota = test_linode_client.load(ObjectStorageQuota, quota_id) + + quota_usage = quota.usage() + + assert isinstance(quota_usage, ObjectStorageQuotaUsage) + assert quota_usage.quota_limit == 100000000 + assert quota_usage.usage >= 0 diff --git a/test/unit/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py index 396813b3d..b7ff7e49c 100644 --- a/test/unit/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -6,6 +6,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageQuota, ) @@ -284,3 +285,53 @@ def test_object_acl_config_update(self): "name": "example", }, ) + + def test_quota_get_and_list(self): + """ + Test that you can get and list an Object storage quota and usage information. + """ + quota = ObjectStorageQuota( + self.client, + "obj-objects-us-ord-1", + ) + + self.assertIsNotNone(quota) + self.assertEqual(quota.quota_id, "obj-objects-us-ord-1") + self.assertEqual(quota.quota_name, "Object Storage Maximum Objects") + self.assertEqual( + quota.description, + "Maximum number of Objects this customer is allowed to have on this endpoint.", + ) + self.assertEqual(quota.endpoint_type, "E1") + self.assertEqual(quota.s3_endpoint, "us-iad-1.linodeobjects.com") + self.assertEqual(quota.quota_limit, 50) + self.assertEqual(quota.resource_metric, "object") + + quota_usage_url = "/object-storage/quotas/obj-objects-us-ord-1/usage" + with self.mock_get(quota_usage_url) as m: + usage = quota.usage() + self.assertIsNotNone(usage) + self.assertEqual(m.call_url, quota_usage_url) + self.assertEqual(usage.quota_limit, 100) + self.assertEqual(usage.usage, 10) + + quota_list_url = "/object-storage/quotas" + with self.mock_get(quota_list_url) as m: + quotas = self.client.object_storage.quotas() + self.assertIsNotNone(quotas) + self.assertEqual(m.call_url, quota_list_url) + self.assertEqual(len(quotas), 2) + self.assertEqual(quotas[0].quota_id, "obj-objects-us-ord-1") + self.assertEqual( + quotas[0].quota_name, "Object Storage Maximum Objects" + ) + self.assertEqual( + quotas[0].description, + "Maximum number of Objects this customer is allowed to have on this endpoint.", + ) + self.assertEqual(quotas[0].endpoint_type, "E1") + self.assertEqual( + quotas[0].s3_endpoint, "us-iad-1.linodeobjects.com" + ) + self.assertEqual(quotas[0].quota_limit, 50) + self.assertEqual(quotas[0].resource_metric, "object") From ace528b2bed8957ffb9371e563f78459ced3f718 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Mon, 12 May 2025 10:52:43 -0700 Subject: [PATCH 302/379] Update test assertion (#546) --- test/integration/models/lke/test_lke.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index e0a9eafb1..3486485d6 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -208,7 +208,10 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: assert _to_comparable(cluster.pools[0]) == _to_comparable(pool) - assert pool.disk_encryption == InstanceDiskEncryptionType.disabled + assert pool.disk_encryption in ( + InstanceDiskEncryptionType.enabled, + InstanceDiskEncryptionType.disabled, + ) def test_cluster_dashboard_url_view(lke_cluster): From a8fa1d7ca3bd95fc88c67391fc56992a8d1fccec Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 12 May 2025 13:53:36 -0400 Subject: [PATCH 303/379] improve test (#547) --- .../models/object_storage/test_obj_quotas.py | 65 ++++++++----------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/test/integration/models/object_storage/test_obj_quotas.py b/test/integration/models/object_storage/test_obj_quotas.py index b1beade44..10a546bc7 100644 --- a/test/integration/models/object_storage/test_obj_quotas.py +++ b/test/integration/models/object_storage/test_obj_quotas.py @@ -1,58 +1,45 @@ +import pytest + from linode_api4.objects.object_storage import ( ObjectStorageQuota, ObjectStorageQuotaUsage, ) -def test_list_obj_storage_quotas(test_linode_client): +def test_list_and_get_obj_storage_quotas(test_linode_client): quotas = test_linode_client.object_storage.quotas() - target_quota_id = "obj-buckets-us-sea-1.linodeobjects.com" - - found_quota = None - for quota in quotas: - if quota.quota_id == target_quota_id: - found_quota = quota - break - - assert ( - found_quota is not None - ), f"Quota with ID {target_quota_id} not found." - - assert found_quota.quota_id == "obj-buckets-us-sea-1.linodeobjects.com" - assert found_quota.quota_name == "max_buckets" - assert found_quota.endpoint_type == "E1" - assert found_quota.s3_endpoint == "us-sea-1.linodeobjects.com" - assert ( - found_quota.description - == "Maximum number of buckets this customer is allowed to have on this endpoint" - ) - assert found_quota.quota_limit == 1000 - assert found_quota.resource_metric == "bucket" + if len(quotas) < 1: + pytest.skip("No available quota for testing. Skipping now...") + found_quota = quotas[0] -def test_get_obj_storage_quota(test_linode_client): - quota_id = "obj-objects-us-ord-1.linodeobjects.com" - quota = test_linode_client.load(ObjectStorageQuota, quota_id) - - assert quota.quota_id == "obj-objects-us-ord-1.linodeobjects.com" - assert quota.quota_name == "max_objects" - assert quota.endpoint_type == "E1" - assert quota.s3_endpoint == "us-ord-1.linodeobjects.com" - assert ( - quota.description - == "Maximum number of objects this customer is allowed to have on this endpoint" + get_quota = test_linode_client.load( + ObjectStorageQuota, found_quota.quota_id ) - assert quota.quota_limit == 100000000 - assert quota.resource_metric == "object" + + assert found_quota.quota_id == get_quota.quota_id + assert found_quota.quota_name == get_quota.quota_name + assert found_quota.endpoint_type == get_quota.endpoint_type + assert found_quota.s3_endpoint == get_quota.s3_endpoint + assert found_quota.description == get_quota.description + assert found_quota.quota_limit == get_quota.quota_limit + assert found_quota.resource_metric == get_quota.resource_metric def test_get_obj_storage_quota_usage(test_linode_client): - quota_id = "obj-objects-us-ord-1.linodeobjects.com" + quotas = test_linode_client.object_storage.quotas() + + if len(quotas) < 1: + pytest.skip("No available quota for testing. Skipping now...") + + quota_id = quotas[0].quota_id quota = test_linode_client.load(ObjectStorageQuota, quota_id) quota_usage = quota.usage() assert isinstance(quota_usage, ObjectStorageQuotaUsage) - assert quota_usage.quota_limit == 100000000 - assert quota_usage.usage >= 0 + assert quota_usage.quota_limit >= 0 + + if quota_usage.usage is not None: + assert quota_usage.usage >= 0 From 40c16306c705155a4901dd92139583ba33c6be55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 10:56:27 -0400 Subject: [PATCH 304/379] build(deps): bump slackapi/slack-github-action from 2.0.0 to 2.1.0 (#548) Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 2.0.0 to 2.1.0. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Commits](https://github.com/slackapi/slack-github-action/compare/v2.0.0...v2.1.0) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-version: 2.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 4 ++-- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/release-notify-slack.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index c0ccc8e87..d08999645 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -232,7 +232,7 @@ jobs: steps: - name: Notify Slack id: main_message - uses: slackapi/slack-github-action@v2.0.0 + uses: slackapi/slack-github-action@v2.1.0 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -264,7 +264,7 @@ jobs: - name: Test summary thread if: success() - uses: slackapi/slack-github-action@v2.0.0 + uses: slackapi/slack-github-action@v2.1.0 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index fc48ee010..3f6083a98 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -45,7 +45,7 @@ jobs: - name: Notify Slack if: always() && github.repository == 'linode/linode_api4-python' - uses: slackapi/slack-github-action@v2.0.0 + uses: slackapi/slack-github-action@v2.1.0 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml index ea1a4da68..f2739e988 100644 --- a/.github/workflows/release-notify-slack.yml +++ b/.github/workflows/release-notify-slack.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Notify Slack - Main Message id: main_message - uses: slackapi/slack-github-action@v2.0.0 + uses: slackapi/slack-github-action@v2.1.0 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} From 3a1ec42866ba04345a98834727d2cc4a3a1b267f Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 20 May 2025 14:49:14 -0400 Subject: [PATCH 305/379] Drop LA and v4beta notice for Limits Visibility (#550) --- linode_api4/objects/object_storage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index 29eba2b06..a2e61405f 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -581,7 +581,6 @@ class ObjectStorageKeys(Base): class ObjectStorageQuota(Base): """ An Object Storage related quota information on your account. - Object Storage Quota related features are under v4beta and may not currently be available to all users. API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-quota """ From 032f29408c13d00e1b985f19f82eed7669ce7437 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 21 May 2025 13:42:38 -0400 Subject: [PATCH 306/379] Proj/configurable db params (#553) * Added support for DB Configurable Params (#527) * Added support for DB Configurable Params features * Added unit tests for config endpoints * Added more unit tests and removed config_update methods * Removed stale fields and updated unit tests * Add integration tests * Add integration tests * remove unused var * remove comments and update test case * update test case * remove assertion * Added support for custom JSON field names in dataclasses * Minor integration test fixes * add get and list test cases --------- Co-authored-by: Youjung Kim * Add / prefix to URL in mysql_config_options and postgresql_config_options API calls (#542) * add test coverage for nullable fields (#541) --------- Co-authored-by: Youjung Kim Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Co-authored-by: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> --- linode_api4/groups/database.py | 54 +- linode_api4/objects/database.py | 151 ++- linode_api4/objects/serializable.py | 18 +- test/fixtures/databases_mysql_config.json | 230 ++++ test/fixtures/databases_mysql_instances.json | 34 +- .../fixtures/databases_postgresql_config.json | 367 +++++ .../databases_postgresql_instances.json | 55 +- test/integration/models/database/helpers.py | 132 ++ .../models/database/test_database.py | 27 +- .../database/test_database_engine_config.py | 475 +++++++ test/unit/groups/database_test.py | 1188 +++++++++++++++++ test/unit/objects/database_test.py | 229 +++- 12 files changed, 2927 insertions(+), 33 deletions(-) create mode 100644 test/fixtures/databases_mysql_config.json create mode 100644 test/fixtures/databases_postgresql_config.json create mode 100644 test/integration/models/database/helpers.py create mode 100644 test/integration/models/database/test_database_engine_config.py diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 8110ea888..fec3df929 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -1,3 +1,9 @@ +from typing import Any, Dict, Union + +from linode_api4 import ( + MySQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigOptions, +) from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( @@ -63,6 +69,26 @@ def engines(self, *filters): """ return self.client._get_and_filter(DatabaseEngine, *filters) + def mysql_config_options(self): + """ + Returns a detailed list of all the configuration options for MySQL Databases. + + API Documentation: TODO + + :returns: The JSON configuration options for MySQL Databases. + """ + return self.client.get("/databases/mysql/config", model=self) + + def postgresql_config_options(self): + """ + Returns a detailed list of all the configuration options for PostgreSQL Databases. + + API Documentation: TODO + + :returns: The JSON configuration options for PostgreSQL Databases. + """ + return self.client.get("/databases/postgresql/config", model=self) + def instances(self, *filters): """ Returns a list of Managed Databases active on this account. @@ -93,7 +119,15 @@ def mysql_instances(self, *filters): """ return self.client._get_and_filter(MySQLDatabase, *filters) - def mysql_create(self, label, region, engine, ltype, **kwargs): + def mysql_create( + self, + label, + region, + engine, + ltype, + engine_config: Union[MySQLDatabaseConfigOptions, Dict[str, Any]] = None, + **kwargs, + ): """ Creates an :any:`MySQLDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -123,6 +157,8 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): :type engine: str or Engine :param ltype: The Linode Type to use for this cluster :type ltype: str or Type + :param engine_config: The configuration options for this MySQL cluster + :type engine_config: Dict[str, Any] or MySQLDatabaseConfigOptions """ params = { @@ -130,6 +166,7 @@ def mysql_create(self, label, region, engine, ltype, **kwargs): "region": region, "engine": engine, "type": ltype, + "engine_config": engine_config, } params.update(kwargs) @@ -216,7 +253,17 @@ def postgresql_instances(self, *filters): """ return self.client._get_and_filter(PostgreSQLDatabase, *filters) - def postgresql_create(self, label, region, engine, ltype, **kwargs): + def postgresql_create( + self, + label, + region, + engine, + ltype, + engine_config: Union[ + PostgreSQLDatabaseConfigOptions, Dict[str, Any] + ] = None, + **kwargs, + ): """ Creates an :any:`PostgreSQLDatabase` on this account with the given label, region, engine, and node type. For example:: @@ -246,6 +293,8 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): :type engine: str or Engine :param ltype: The Linode Type to use for this cluster :type ltype: str or Type + :param engine_config: The configuration options for this PostgreSQL cluster + :type engine_config: Dict[str, Any] or PostgreSQLDatabaseConfigOptions """ params = { @@ -253,6 +302,7 @@ def postgresql_create(self, label, region, engine, ltype, **kwargs): "region": region, "engine": engine, "type": ltype, + "engine_config": engine_config, } params.update(kwargs) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index dc9db8471..39249bbf9 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,6 +1,15 @@ +from dataclasses import dataclass, field +from typing import Optional + from deprecated import deprecated -from linode_api4.objects import Base, DerivedBase, MappedObject, Property +from linode_api4.objects import ( + Base, + DerivedBase, + JSONObject, + MappedObject, + Property, +) class DatabaseType(Base): @@ -128,6 +137,140 @@ class PostgreSQLDatabaseBackup(DatabaseBackup): api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" +@dataclass +class MySQLDatabaseConfigMySQLOptions(JSONObject): + """ + MySQLDatabaseConfigMySQLOptions represents the fields in the mysql + field of the MySQLDatabaseConfigOptions class + """ + + connect_timeout: Optional[int] = None + default_time_zone: Optional[str] = None + group_concat_max_len: Optional[float] = None + information_schema_stats_expiry: Optional[int] = None + innodb_change_buffer_max_size: Optional[int] = None + innodb_flush_neighbors: Optional[int] = None + innodb_ft_min_token_size: Optional[int] = None + innodb_ft_server_stopword_table: Optional[str] = None + innodb_lock_wait_timeout: Optional[int] = None + innodb_log_buffer_size: Optional[int] = None + innodb_online_alter_log_max_size: Optional[int] = None + innodb_read_io_threads: Optional[int] = None + innodb_rollback_on_timeout: Optional[bool] = None + innodb_thread_concurrency: Optional[int] = None + innodb_write_io_threads: Optional[int] = None + interactive_timeout: Optional[int] = None + internal_tmp_mem_storage_engine: Optional[str] = None + max_allowed_packet: Optional[int] = None + max_heap_table_size: Optional[int] = None + net_buffer_length: Optional[int] = None + net_read_timeout: Optional[int] = None + net_write_timeout: Optional[int] = None + sort_buffer_size: Optional[int] = None + sql_mode: Optional[str] = None + sql_require_primary_key: Optional[bool] = None + tmp_table_size: Optional[int] = None + wait_timeout: Optional[int] = None + + +@dataclass +class MySQLDatabaseConfigOptions(JSONObject): + """ + MySQLDatabaseConfigOptions is used to specify + a MySQL Database Cluster's configuration options during its creation. + """ + + mysql: Optional[MySQLDatabaseConfigMySQLOptions] = None + binlog_retention_period: Optional[int] = None + + +@dataclass +class PostgreSQLDatabaseConfigPGLookoutOptions(JSONObject): + """ + PostgreSQLDatabasePGLookoutConfigOptions represents the fields in the pglookout + field of the PostgreSQLDatabasePGConfigOptions class + """ + + max_failover_replication_time_lag: Optional[int] = None + + +@dataclass +class PostgreSQLDatabaseConfigPGOptions(JSONObject): + """ + PostgreSQLDatabasePGConfigOptions represents the fields in the pg + field of the PostgreSQLDatabasePGConfigOptions class + """ + + autovacuum_analyze_scale_factor: Optional[float] = None + autovacuum_analyze_threshold: Optional[int] = None + autovacuum_max_workers: Optional[int] = None + autovacuum_naptime: Optional[int] = None + autovacuum_vacuum_cost_delay: Optional[int] = None + autovacuum_vacuum_cost_limit: Optional[int] = None + autovacuum_vacuum_scale_factor: Optional[float] = None + autovacuum_vacuum_threshold: Optional[int] = None + bgwriter_delay: Optional[int] = None + bgwriter_flush_after: Optional[int] = None + bgwriter_lru_maxpages: Optional[int] = None + bgwriter_lru_multiplier: Optional[float] = None + deadlock_timeout: Optional[int] = None + default_toast_compression: Optional[str] = None + idle_in_transaction_session_timeout: Optional[int] = None + jit: Optional[bool] = None + max_files_per_process: Optional[int] = None + max_locks_per_transaction: Optional[int] = None + max_logical_replication_workers: Optional[int] = None + max_parallel_workers: Optional[int] = None + max_parallel_workers_per_gather: Optional[int] = None + max_pred_locks_per_transaction: Optional[int] = None + max_replication_slots: Optional[int] = None + max_slot_wal_keep_size: Optional[int] = None + max_stack_depth: Optional[int] = None + max_standby_archive_delay: Optional[int] = None + max_standby_streaming_delay: Optional[int] = None + max_wal_senders: Optional[int] = None + max_worker_processes: Optional[int] = None + password_encryption: Optional[str] = None + pg_partman_bgw_interval: Optional[int] = field( + default=None, metadata={"json_key": "pg_partman_bgw.interval"} + ) + pg_partman_bgw_role: Optional[str] = field( + default=None, metadata={"json_key": "pg_partman_bgw.role"} + ) + pg_stat_monitor_pgsm_enable_query_plan: Optional[bool] = field( + default=None, + metadata={"json_key": "pg_stat_monitor.pgsm_enable_query_plan"}, + ) + pg_stat_monitor_pgsm_max_buckets: Optional[int] = field( + default=None, metadata={"json_key": "pg_stat_monitor.pgsm_max_buckets"} + ) + pg_stat_statements_track: Optional[str] = field( + default=None, metadata={"json_key": "pg_stat_statements.track"} + ) + temp_file_limit: Optional[int] = None + timezone: Optional[str] = None + track_activity_query_size: Optional[int] = None + track_commit_timestamp: Optional[str] = None + track_functions: Optional[str] = None + track_io_timing: Optional[str] = None + wal_sender_timeout: Optional[int] = None + wal_writer_delay: Optional[int] = None + + +@dataclass +class PostgreSQLDatabaseConfigOptions(JSONObject): + """ + PostgreSQLDatabaseConfigOptions is used to specify + a PostgreSQL Database Cluster's configuration options during its creation. + """ + + pg: Optional[PostgreSQLDatabaseConfigPGOptions] = None + pg_stat_monitor_enable: Optional[bool] = None + pglookout: Optional[PostgreSQLDatabaseConfigPGLookoutOptions] = None + shared_buffers_percentage: Optional[float] = None + work_mem: Optional[int] = None + + class MySQLDatabase(Base): """ An accessible Managed MySQL Database. @@ -158,6 +301,9 @@ class MySQLDatabase(Base): "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), "version": Property(), + "engine_config": Property( + mutable=True, json_object=MySQLDatabaseConfigOptions + ), } @property @@ -321,6 +467,9 @@ class PostgreSQLDatabase(Base): "updated": Property(volatile=True, is_datetime=True), "updates": Property(mutable=True), "version": Property(), + "engine_config": Property( + mutable=True, json_object=PostgreSQLDatabaseConfigOptions + ), } @property diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index e33179a60..1660795aa 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -148,7 +148,7 @@ def _parse_attr(cls, json_value: Any, field_type: type): @classmethod def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: """ - Creates an instance of this class from a JSON dict. + Creates an instance of this class from a JSON dict, respecting json_key metadata. """ if json is None: return None @@ -157,8 +157,12 @@ def from_json(cls, json: Dict[str, Any]) -> Optional["JSONObject"]: type_hints = get_type_hints(cls) - for k in vars(obj): - setattr(obj, k, cls._parse_attr(json.get(k), type_hints.get(k))) + for f in fields(cls): + json_key = f.metadata.get("json_key", f.name) + field_type = type_hints.get(f.name) + value = json.get(json_key) + parsed_value = cls._parse_attr(value, field_type) + setattr(obj, f.name, parsed_value) return obj @@ -211,7 +215,11 @@ def should_include(key: str, value: Any) -> bool: result = {} - for k, v in vars(self).items(): + for f in fields(self): + k = f.name + json_key = f.metadata.get("json_key", k) + v = getattr(self, k) + if not should_include(k, v): continue @@ -222,7 +230,7 @@ def should_include(key: str, value: Any) -> bool: else: v = attempt_serialize(v) - result[k] = v + result[json_key] = v return result diff --git a/test/fixtures/databases_mysql_config.json b/test/fixtures/databases_mysql_config.json new file mode 100644 index 000000000..9cba0afd4 --- /dev/null +++ b/test/fixtures/databases_mysql_config.json @@ -0,0 +1,230 @@ +{ + "mysql": { + "connect_timeout": { + "description": "The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake", + "example": 10, + "maximum": 3600, + "minimum": 2, + "requires_restart": false, + "type": "integer" + }, + "default_time_zone": { + "description": "Default server time zone as an offset from UTC (from -12:00 to +12:00), a time zone name, or 'SYSTEM' to use the MySQL server default.", + "example": "+03:00", + "maxLength": 100, + "minLength": 2, + "pattern": "^([-+][\\d:]*|[\\w/]*)$", + "requires_restart": false, + "type": "string" + }, + "group_concat_max_len": { + "description": "The maximum permitted result length in bytes for the GROUP_CONCAT() function.", + "example": 1024, + "maximum": 18446744073709551600, + "minimum": 4, + "requires_restart": false, + "type": "integer" + }, + "information_schema_stats_expiry": { + "description": "The time, in seconds, before cached statistics expire", + "example": 86400, + "maximum": 31536000, + "minimum": 900, + "requires_restart": false, + "type": "integer" + }, + "innodb_change_buffer_max_size": { + "description": "Maximum size for the InnoDB change buffer, as a percentage of the total size of the buffer pool. Default is 25", + "example": 30, + "maximum": 50, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "innodb_flush_neighbors": { + "description": "Specifies whether flushing a page from the InnoDB buffer pool also flushes other dirty pages in the same extent (default is 1): 0 - dirty pages in the same extent are not flushed, 1 - flush contiguous dirty pages in the same extent, 2 - flush dirty pages in the same extent", + "example": 0, + "maximum": 2, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "innodb_ft_min_token_size": { + "description": "Minimum length of words that are stored in an InnoDB FULLTEXT index. Changing this parameter will lead to a restart of the MySQL service.", + "example": 3, + "maximum": 16, + "minimum": 0, + "requires_restart": true, + "type": "integer" + }, + "innodb_ft_server_stopword_table": { + "description": "This option is used to specify your own InnoDB FULLTEXT index stopword list for all InnoDB tables.", + "example": "db_name/table_name", + "maxLength": 1024, + "pattern": "^.+/.+$", + "requires_restart": false, + "type": [ + "null", + "string" + ] + }, + "innodb_lock_wait_timeout": { + "description": "The length of time in seconds an InnoDB transaction waits for a row lock before giving up. Default is 120.", + "example": 50, + "maximum": 3600, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "innodb_log_buffer_size": { + "description": "The size in bytes of the buffer that InnoDB uses to write to the log files on disk.", + "example": 16777216, + "maximum": 4294967295, + "minimum": 1048576, + "requires_restart": false, + "type": "integer" + }, + "innodb_online_alter_log_max_size": { + "description": "The upper limit in bytes on the size of the temporary log files used during online DDL operations for InnoDB tables.", + "example": 134217728, + "maximum": 1099511627776, + "minimum": 65536, + "requires_restart": false, + "type": "integer" + }, + "innodb_read_io_threads": { + "description": "The number of I/O threads for read operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + "example": 10, + "maximum": 64, + "minimum": 1, + "requires_restart": true, + "type": "integer" + }, + "innodb_rollback_on_timeout": { + "description": "When enabled a transaction timeout causes InnoDB to abort and roll back the entire transaction. Changing this parameter will lead to a restart of the MySQL service.", + "example": true, + "requires_restart": true, + "type": "boolean" + }, + "innodb_thread_concurrency": { + "description": "Defines the maximum number of threads permitted inside of InnoDB. Default is 0 (infinite concurrency - no limit)", + "example": 10, + "maximum": 1000, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "innodb_write_io_threads": { + "description": "The number of I/O threads for write operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + "example": 10, + "maximum": 64, + "minimum": 1, + "requires_restart": true, + "type": "integer" + }, + "interactive_timeout": { + "description": "The number of seconds the server waits for activity on an interactive connection before closing it.", + "example": 3600, + "maximum": 604800, + "minimum": 30, + "requires_restart": false, + "type": "integer" + }, + "internal_tmp_mem_storage_engine": { + "description": "The storage engine for in-memory internal temporary tables.", + "enum": [ + "TempTable", + "MEMORY" + ], + "example": "TempTable", + "requires_restart": false, + "type": "string" + }, + "max_allowed_packet": { + "description": "Size of the largest message in bytes that can be received by the server. Default is 67108864 (64M)", + "example": 67108864, + "maximum": 1073741824, + "minimum": 102400, + "requires_restart": false, + "type": "integer" + }, + "max_heap_table_size": { + "description": "Limits the size of internal in-memory tables. Also set tmp_table_size. Default is 16777216 (16M)", + "example": 16777216, + "maximum": 1073741824, + "minimum": 1048576, + "requires_restart": false, + "type": "integer" + }, + "net_buffer_length": { + "description": "Start sizes of connection buffer and result buffer. Default is 16384 (16K). Changing this parameter will lead to a restart of the MySQL service.", + "example": 16384, + "maximum": 1048576, + "minimum": 1024, + "requires_restart": true, + "type": "integer" + }, + "net_read_timeout": { + "description": "The number of seconds to wait for more data from a connection before aborting the read.", + "example": 30, + "maximum": 3600, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "net_write_timeout": { + "description": "The number of seconds to wait for a block to be written to a connection before aborting the write.", + "example": 30, + "maximum": 3600, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "sort_buffer_size": { + "description": "Sort buffer size in bytes for ORDER BY optimization. Default is 262144 (256K)", + "example": 262144, + "maximum": 1073741824, + "minimum": 32768, + "requires_restart": false, + "type": "integer" + }, + "sql_mode": { + "description": "Global SQL mode. Set to empty to use MySQL server defaults. When creating a new service and not setting this field Akamai default SQL mode (strict, SQL standard compliant) will be assigned.", + "example": "ANSI,TRADITIONAL", + "maxLength": 1024, + "pattern": "^[A-Z_]*(,[A-Z_]+)*$", + "requires_restart": false, + "type": "string" + }, + "sql_require_primary_key": { + "description": "Require primary key to be defined for new tables or old tables modified with ALTER TABLE and fail if missing. It is recommended to always have primary keys because various functionality may break if any large table is missing them.", + "example": true, + "requires_restart": false, + "type": "boolean" + }, + "tmp_table_size": { + "description": "Limits the size of internal in-memory tables. Also set max_heap_table_size. Default is 16777216 (16M)", + "example": 16777216, + "maximum": 1073741824, + "minimum": 1048576, + "requires_restart": false, + "type": "integer" + }, + "wait_timeout": { + "description": "The number of seconds the server waits for activity on a noninteractive connection before closing it.", + "example": 28800, + "maximum": 2147483, + "minimum": 1, + "requires_restart": false, + "type": "integer" + } + }, + "binlog_retention_period": { + "description": "The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.", + "example": 600, + "maximum": 86400, + "minimum": 600, + "requires_restart": false, + "type": "integer" + } +} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json index 2ea73ddc2..d6e3f2e64 100644 --- a/test/fixtures/databases_mysql_instances.json +++ b/test/fixtures/databases_mysql_instances.json @@ -29,7 +29,39 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "8.0.26" + "version": "8.0.26", + "engine_config": { + "binlog_retention_period": 600, + "mysql": { + "connect_timeout": 10, + "default_time_zone": "+03:00", + "group_concat_max_len": 1024, + "information_schema_stats_expiry": 86400, + "innodb_change_buffer_max_size": 30, + "innodb_flush_neighbors": 0, + "innodb_ft_min_token_size": 3, + "innodb_ft_server_stopword_table": "db_name/table_name", + "innodb_lock_wait_timeout": 50, + "innodb_log_buffer_size": 16777216, + "innodb_online_alter_log_max_size": 134217728, + "innodb_read_io_threads": 10, + "innodb_rollback_on_timeout": true, + "innodb_thread_concurrency": 10, + "innodb_write_io_threads": 10, + "interactive_timeout": 3600, + "internal_tmp_mem_storage_engine": "TempTable", + "max_allowed_packet": 67108864, + "max_heap_table_size": 16777216, + "net_buffer_length": 16384, + "net_read_timeout": 30, + "net_write_timeout": 30, + "sort_buffer_size": 262144, + "sql_mode": "ANSI,TRADITIONAL", + "sql_require_primary_key": true, + "tmp_table_size": 16777216, + "wait_timeout": 28800 + } + } } ], "page": 1, diff --git a/test/fixtures/databases_postgresql_config.json b/test/fixtures/databases_postgresql_config.json new file mode 100644 index 000000000..9a93d0aa9 --- /dev/null +++ b/test/fixtures/databases_postgresql_config.json @@ -0,0 +1,367 @@ +{ + "pg": { + "autovacuum_analyze_scale_factor": { + "description": "Specifies a fraction of the table size to add to autovacuum_analyze_threshold when deciding whether to trigger an ANALYZE. The default is 0.2 (20% of table size)", + "maximum": 1.0, + "minimum": 0.0, + "requires_restart": false, + "type": "number" + }, + "autovacuum_analyze_threshold": { + "description": "Specifies the minimum number of inserted, updated or deleted tuples needed to trigger an ANALYZE in any one table. The default is 50 tuples.", + "maximum": 2147483647, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_max_workers": { + "description": "Specifies the maximum number of autovacuum processes (other than the autovacuum launcher) that may be running at any one time. The default is three. This parameter can only be set at server start.", + "maximum": 20, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_naptime": { + "description": "Specifies the minimum delay between autovacuum runs on any given database. The delay is measured in seconds, and the default is one minute", + "maximum": 86400, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_vacuum_cost_delay": { + "description": "Specifies the cost delay value that will be used in automatic VACUUM operations. If -1 is specified, the regular vacuum_cost_delay value will be used. The default value is 20 milliseconds", + "maximum": 100, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_vacuum_cost_limit": { + "description": "Specifies the cost limit value that will be used in automatic VACUUM operations. If -1 is specified (which is the default), the regular vacuum_cost_limit value will be used.", + "maximum": 10000, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "autovacuum_vacuum_scale_factor": { + "description": "Specifies a fraction of the table size to add to autovacuum_vacuum_threshold when deciding whether to trigger a VACUUM. The default is 0.2 (20% of table size)", + "maximum": 1.0, + "minimum": 0.0, + "requires_restart": false, + "type": "number" + }, + "autovacuum_vacuum_threshold": { + "description": "Specifies the minimum number of updated or deleted tuples needed to trigger a VACUUM in any one table. The default is 50 tuples", + "maximum": 2147483647, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_delay": { + "description": "Specifies the delay between activity rounds for the background writer in milliseconds. Default is 200.", + "example": 200, + "maximum": 10000, + "minimum": 10, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_flush_after": { + "description": "Whenever more than bgwriter_flush_after bytes have been written by the background writer, attempt to force the OS to issue these writes to the underlying storage. Specified in kilobytes, default is 512. Setting of 0 disables forced writeback.", + "example": 512, + "maximum": 2048, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_lru_maxpages": { + "description": "In each round, no more than this many buffers will be written by the background writer. Setting this to zero disables background writing. Default is 100.", + "example": 100, + "maximum": 1073741823, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "bgwriter_lru_multiplier": { + "description": "The average recent need for new buffers is multiplied by bgwriter_lru_multiplier to arrive at an estimate of the number that will be needed during the next round, (up to bgwriter_lru_maxpages). 1.0 represents a \u201cjust in time\u201d policy of writing exactly the number of buffers predicted to be needed. Larger values provide some cushion against spikes in demand, while smaller values intentionally leave writes to be done by server processes. The default is 2.0.", + "example": 2.0, + "maximum": 10, + "minimum": 0, + "requires_restart": false, + "type": "number" + }, + "deadlock_timeout": { + "description": "This is the amount of time, in milliseconds, to wait on a lock before checking to see if there is a deadlock condition.", + "example": 1000, + "maximum": 1800000, + "minimum": 500, + "requires_restart": false, + "type": "integer" + }, + "default_toast_compression": { + "description": "Specifies the default TOAST compression method for values of compressible columns (the default is lz4).", + "enum": [ + "lz4", + "pglz" + ], + "example": "lz4", + "requires_restart": false, + "type": "string" + }, + "idle_in_transaction_session_timeout": { + "description": "Time out sessions with open transactions after this number of milliseconds", + "maximum": 604800000, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "jit": { + "description": "Controls system-wide use of Just-in-Time Compilation (JIT).", + "example": true, + "requires_restart": false, + "type": "boolean" + }, + "max_files_per_process": { + "description": "PostgreSQL maximum number of files that can be open per process", + "maximum": 4096, + "minimum": 1000, + "requires_restart": false, + "type": "integer" + }, + "max_locks_per_transaction": { + "description": "PostgreSQL maximum locks per transaction", + "maximum": 6400, + "minimum": 64, + "requires_restart": false, + "type": "integer" + }, + "max_logical_replication_workers": { + "description": "PostgreSQL maximum logical replication workers (taken from the pool of max_parallel_workers)", + "maximum": 64, + "minimum": 4, + "requires_restart": false, + "type": "integer" + }, + "max_parallel_workers": { + "description": "Sets the maximum number of workers that the system can support for parallel queries", + "maximum": 96, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "max_parallel_workers_per_gather": { + "description": "Sets the maximum number of workers that can be started by a single Gather or Gather Merge node", + "maximum": 96, + "minimum": 0, + "requires_restart": false, + "type": "integer" + }, + "max_pred_locks_per_transaction": { + "description": "PostgreSQL maximum predicate locks per transaction", + "maximum": 5120, + "minimum": 64, + "requires_restart": false, + "type": "integer" + }, + "max_replication_slots": { + "description": "PostgreSQL maximum replication slots", + "maximum": 64, + "minimum": 8, + "requires_restart": false, + "type": "integer" + }, + "max_slot_wal_keep_size": { + "description": "PostgreSQL maximum WAL size (MB) reserved for replication slots. Default is -1 (unlimited). wal_keep_size minimum WAL size setting takes precedence over this.", + "maximum": 2147483647, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "max_stack_depth": { + "description": "Maximum depth of the stack in bytes", + "maximum": 6291456, + "minimum": 2097152, + "requires_restart": false, + "type": "integer" + }, + "max_standby_archive_delay": { + "description": "Max standby archive delay in milliseconds", + "maximum": 43200000, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "max_standby_streaming_delay": { + "description": "Max standby streaming delay in milliseconds", + "maximum": 43200000, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "max_wal_senders": { + "description": "PostgreSQL maximum WAL senders", + "maximum": 64, + "minimum": 20, + "requires_restart": false, + "type": "integer" + }, + "max_worker_processes": { + "description": "Sets the maximum number of background processes that the system can support", + "maximum": 96, + "minimum": 8, + "requires_restart": false, + "type": "integer" + }, + "password_encryption": { + "description": "Chooses the algorithm for encrypting passwords.", + "enum": [ + "md5", + "scram-sha-256" + ], + "example": "scram-sha-256", + "requires_restart": false, + "type": [ + "string", + "null" + ] + }, + "pg_partman_bgw.interval": { + "description": "Sets the time interval to run pg_partman's scheduled tasks", + "example": 3600, + "maximum": 604800, + "minimum": 3600, + "requires_restart": false, + "type": "integer" + }, + "pg_partman_bgw.role": { + "description": "Controls which role to use for pg_partman's scheduled background tasks.", + "example": "myrolename", + "maxLength": 64, + "pattern": "^[_A-Za-z0-9][-._A-Za-z0-9]{0,63}$", + "requires_restart": false, + "type": "string" + }, + "pg_stat_monitor.pgsm_enable_query_plan": { + "description": "Enables or disables query plan monitoring", + "example": false, + "requires_restart": false, + "type": "boolean" + }, + "pg_stat_monitor.pgsm_max_buckets": { + "description": "Sets the maximum number of buckets", + "example": 10, + "maximum": 10, + "minimum": 1, + "requires_restart": false, + "type": "integer" + }, + "pg_stat_statements.track": { + "description": "Controls which statements are counted. Specify top to track top-level statements (those issued directly by clients), all to also track nested statements (such as statements invoked within functions), or none to disable statement statistics collection. The default value is top.", + "enum": [ + "all", + "top", + "none" + ], + "requires_restart": false, + "type": [ + "string" + ] + }, + "temp_file_limit": { + "description": "PostgreSQL temporary file limit in KiB, -1 for unlimited", + "example": 5000000, + "maximum": 2147483647, + "minimum": -1, + "requires_restart": false, + "type": "integer" + }, + "timezone": { + "description": "PostgreSQL service timezone", + "example": "Europe/Helsinki", + "maxLength": 64, + "pattern": "^[\\w/]*$", + "requires_restart": false, + "type": "string" + }, + "track_activity_query_size": { + "description": "Specifies the number of bytes reserved to track the currently executing command for each active session.", + "example": 1024, + "maximum": 10240, + "minimum": 1024, + "requires_restart": false, + "type": "integer" + }, + "track_commit_timestamp": { + "description": "Record commit time of transactions.", + "enum": [ + "off", + "on" + ], + "example": "off", + "requires_restart": false, + "type": "string" + }, + "track_functions": { + "description": "Enables tracking of function call counts and time used.", + "enum": [ + "all", + "pl", + "none" + ], + "requires_restart": false, + "type": "string" + }, + "track_io_timing": { + "description": "Enables timing of database I/O calls. This parameter is off by default, because it will repeatedly query the operating system for the current time, which may cause significant overhead on some platforms.", + "enum": [ + "off", + "on" + ], + "example": "off", + "requires_restart": false, + "type": "string" + }, + "wal_sender_timeout": { + "description": "Terminate replication connections that are inactive for longer than this amount of time, in milliseconds. Setting this value to zero disables the timeout.", + "example": 60000, + "requires_restart": false, + "type": "integer" + }, + "wal_writer_delay": { + "description": "WAL flush interval in milliseconds. Note that setting this value to lower than the default 200ms may negatively impact performance", + "example": 50, + "maximum": 200, + "minimum": 10, + "requires_restart": false, + "type": "integer" + } + }, + "pg_stat_monitor_enable": { + "description": "Enable the pg_stat_monitor extension. Enabling this extension will cause the cluster to be restarted. When this extension is enabled, pg_stat_statements results for utility commands are unreliable", + "requires_restart": true, + "type": "boolean" + }, + "pglookout": { + "max_failover_replication_time_lag": { + "description": "Number of seconds of master unavailability before triggering database failover to standby", + "maximum": 9223372036854775000, + "minimum": 10, + "requires_restart": false, + "type": "integer" + } + }, + "shared_buffers_percentage": { + "description": "Percentage of total RAM that the database server uses for shared memory buffers. Valid range is 20-60 (float), which corresponds to 20% - 60%. This setting adjusts the shared_buffers configuration value.", + "example": 41.5, + "maximum": 60.0, + "minimum": 20.0, + "requires_restart": false, + "type": "number" + }, + "work_mem": { + "description": "Sets the maximum amount of memory to be used by a query operation (such as a sort or hash table) before writing to temporary disk files, in MB. Default is 1MB + 0.075% of total RAM (up to 32MB).", + "example": 4, + "maximum": 1024, + "minimum": 1, + "requires_restart": false, + "type": "integer" + } +} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json index 2740b836d..92d5ce945 100644 --- a/test/fixtures/databases_postgresql_instances.json +++ b/test/fixtures/databases_postgresql_instances.json @@ -30,7 +30,60 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "13.2" + "version": "13.2", + "engine_config": { + "pg": { + "autovacuum_analyze_scale_factor": 0.5, + "autovacuum_analyze_threshold": 100, + "autovacuum_max_workers": 10, + "autovacuum_naptime": 100, + "autovacuum_vacuum_cost_delay": 50, + "autovacuum_vacuum_cost_limit": 100, + "autovacuum_vacuum_scale_factor": 0.5, + "autovacuum_vacuum_threshold": 100, + "bgwriter_delay": 200, + "bgwriter_flush_after": 512, + "bgwriter_lru_maxpages": 100, + "bgwriter_lru_multiplier": 2.0, + "deadlock_timeout": 1000, + "default_toast_compression": "lz4", + "idle_in_transaction_session_timeout": 100, + "jit": true, + "max_files_per_process": 100, + "max_locks_per_transaction": 100, + "max_logical_replication_workers": 32, + "max_parallel_workers": 64, + "max_parallel_workers_per_gather": 64, + "max_pred_locks_per_transaction": 1000, + "max_replication_slots": 32, + "max_slot_wal_keep_size": 100, + "max_stack_depth": 3507152, + "max_standby_archive_delay": 1000, + "max_standby_streaming_delay": 1000, + "max_wal_senders": 32, + "max_worker_processes": 64, + "password_encryption": "scram-sha-256", + "pg_partman_bgw.interval": 3600, + "pg_partman_bgw.role": "myrolename", + "pg_stat_monitor.pgsm_enable_query_plan": false, + "pg_stat_monitor.pgsm_max_buckets": 10, + "pg_stat_statements.track": "top", + "temp_file_limit": 5000000, + "timezone": "Europe/Helsinki", + "track_activity_query_size": 1024, + "track_commit_timestamp": "off", + "track_functions": "all", + "track_io_timing": "off", + "wal_sender_timeout": 60000, + "wal_writer_delay": 50 + }, + "pg_stat_monitor_enable": true, + "pglookout": { + "max_failover_replication_time_lag": 1000 + }, + "shared_buffers_percentage": 41.5, + "work_mem": 4 + } } ], "page": 1, diff --git a/test/integration/models/database/helpers.py b/test/integration/models/database/helpers.py new file mode 100644 index 000000000..134e7e7c2 --- /dev/null +++ b/test/integration/models/database/helpers.py @@ -0,0 +1,132 @@ +from linode_api4 import LinodeClient +from linode_api4.objects import ( + MySQLDatabase, + MySQLDatabaseConfigMySQLOptions, + MySQLDatabaseConfigOptions, + PostgreSQLDatabase, + PostgreSQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigPGOptions, +) + + +# Test Helpers +def get_db_engine_id(client: LinodeClient, engine: str): + engines = client.database.engines() + engine_id = "" + for e in engines: + if e.engine == engine: + engine_id = e.id + + return str(engine_id) + + +def get_sql_db_status(client: LinodeClient, db_id, status: str): + db = client.load(MySQLDatabase, db_id) + return db.status == status + + +def get_postgres_db_status(client: LinodeClient, db_id, status: str): + db = client.load(PostgreSQLDatabase, db_id) + return db.status == status + + +def make_full_mysql_engine_config(): + return MySQLDatabaseConfigOptions( + binlog_retention_period=600, + mysql=MySQLDatabaseConfigMySQLOptions( + connect_timeout=20, + default_time_zone="+00:00", + group_concat_max_len=1024, + information_schema_stats_expiry=900, + innodb_change_buffer_max_size=25, + innodb_flush_neighbors=1, + innodb_ft_min_token_size=3, + innodb_ft_server_stopword_table="db_name/table_name", + innodb_lock_wait_timeout=50, + innodb_log_buffer_size=16777216, + innodb_online_alter_log_max_size=134217728, + innodb_read_io_threads=4, + innodb_rollback_on_timeout=True, + innodb_thread_concurrency=8, + innodb_write_io_threads=4, + interactive_timeout=300, + internal_tmp_mem_storage_engine="TempTable", + max_allowed_packet=67108864, + max_heap_table_size=16777216, + net_buffer_length=16384, + net_read_timeout=30, + net_write_timeout=60, + sort_buffer_size=262144, + sql_mode="TRADITIONAL", + sql_require_primary_key=False, + tmp_table_size=16777216, + wait_timeout=28800, + ), + ) + + +def make_mysql_engine_config_w_nullable_field(): + return MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions( + innodb_ft_server_stopword_table=None, + ), + ) + + +def make_full_postgres_engine_config(): + return PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_scale_factor=0.1, + autovacuum_analyze_threshold=50, + autovacuum_max_workers=3, + autovacuum_naptime=60, + autovacuum_vacuum_cost_delay=20, + autovacuum_vacuum_cost_limit=200, + autovacuum_vacuum_scale_factor=0.2, + autovacuum_vacuum_threshold=50, + bgwriter_delay=200, + bgwriter_flush_after=64, + bgwriter_lru_maxpages=100, + bgwriter_lru_multiplier=2.0, + deadlock_timeout=1000, + default_toast_compression="lz4", + idle_in_transaction_session_timeout=600000, + jit=True, + max_files_per_process=1000, + max_locks_per_transaction=64, + max_logical_replication_workers=4, + max_parallel_workers=4, + max_parallel_workers_per_gather=2, + max_pred_locks_per_transaction=64, + max_replication_slots=10, + max_slot_wal_keep_size=2048, + max_stack_depth=6291456, + max_standby_archive_delay=30000, + max_standby_streaming_delay=30000, + max_wal_senders=20, + max_worker_processes=8, + password_encryption="scram-sha-256", + temp_file_limit=1, + timezone="UTC", + track_activity_query_size=2048, + track_functions="all", + wal_sender_timeout=60000, + wal_writer_delay=200, + pg_partman_bgw_interval=3600, + pg_partman_bgw_role="myrolename", + pg_stat_monitor_pgsm_enable_query_plan=True, + pg_stat_monitor_pgsm_max_buckets=2, + pg_stat_statements_track="top", + ), + pg_stat_monitor_enable=True, + shared_buffers_percentage=25.0, + work_mem=1024, + ) + + +def make_postgres_engine_config_w_password_encryption_null(): + return PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + password_encryption=None, + ), + ) diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index 351c09c2a..dbb763c55 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -5,34 +5,17 @@ send_request_when_resource_available, wait_for_condition, ) +from test.integration.models.database.helpers import ( + get_db_engine_id, + get_postgres_db_status, + get_sql_db_status, +) import pytest -from linode_api4 import LinodeClient from linode_api4.objects import MySQLDatabase, PostgreSQLDatabase -# Test Helpers -def get_db_engine_id(client: LinodeClient, engine: str): - engines = client.database.engines() - engine_id = "" - for e in engines: - if e.engine == engine: - engine_id = e.id - - return str(engine_id) - - -def get_sql_db_status(client: LinodeClient, db_id, status: str): - db = client.load(MySQLDatabase, db_id) - return db.status == status - - -def get_postgres_db_status(client: LinodeClient, db_id, status: str): - db = client.load(PostgreSQLDatabase, db_id) - return db.status == status - - @pytest.fixture(scope="session") def test_create_sql_db(test_linode_client): client = test_linode_client diff --git a/test/integration/models/database/test_database_engine_config.py b/test/integration/models/database/test_database_engine_config.py new file mode 100644 index 000000000..446281a2d --- /dev/null +++ b/test/integration/models/database/test_database_engine_config.py @@ -0,0 +1,475 @@ +import os +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) +from test.integration.models.database.helpers import ( + get_db_engine_id, + get_postgres_db_status, + get_sql_db_status, + make_full_mysql_engine_config, + make_full_postgres_engine_config, + make_mysql_engine_config_w_nullable_field, + make_postgres_engine_config_w_password_encryption_null, +) + +import pytest + +from linode_api4.errors import ApiError +from linode_api4.objects import ( + MySQLDatabase, + MySQLDatabaseConfigMySQLOptions, + MySQLDatabaseConfigOptions, + PostgreSQLDatabase, + PostgreSQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigPGOptions, +) + + +@pytest.fixture(scope="session") +def mysql_db_with_engine_config(test_linode_client): + client = test_linode_client + label = get_test_label() + "-sqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_full_mysql_engine_config(), + ) + + def get_db_status(): + return db.status == "active" + + # Usually take 10-15m to provision + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +@pytest.fixture(scope="session") +def postgres_db_with_engine_config(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = "postgresql/17" + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_full_postgres_engine_config(), + ) + + def get_db_status(): + return db.status == "active" + + # Usually take 10-15m to provision + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +# MYSQL +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_mysql_config(test_linode_client): + config = test_linode_client.database.mysql_config_options() + + # Top-level keys + assert "binlog_retention_period" in config + assert "mysql" in config + + # binlog_retention_period checks + brp = config["binlog_retention_period"] + assert isinstance(brp, dict) + assert brp["type"] == "integer" + assert brp["minimum"] == 600 + assert brp["maximum"] == 86400 + assert brp["requires_restart"] is False + + # mysql sub-keys + mysql = config["mysql"] + + # mysql valid fields + expected_keys = [ + "connect_timeout", + "default_time_zone", + "group_concat_max_len", + "information_schema_stats_expiry", + "innodb_change_buffer_max_size", + "innodb_flush_neighbors", + "innodb_ft_min_token_size", + "innodb_ft_server_stopword_table", + "innodb_lock_wait_timeout", + "innodb_log_buffer_size", + "innodb_online_alter_log_max_size", + "innodb_read_io_threads", + "innodb_rollback_on_timeout", + "innodb_thread_concurrency", + "innodb_write_io_threads", + "interactive_timeout", + "internal_tmp_mem_storage_engine", + "max_allowed_packet", + "max_heap_table_size", + "net_buffer_length", + "net_read_timeout", + "net_write_timeout", + "sort_buffer_size", + "sql_mode", + "sql_require_primary_key", + "tmp_table_size", + "wait_timeout", + ] + + # Assert all valid fields are present + for key in expected_keys: + assert key in mysql, f"{key} not found in mysql config" + + assert mysql["connect_timeout"]["type"] == "integer" + assert mysql["default_time_zone"]["type"] == "string" + assert mysql["innodb_rollback_on_timeout"]["type"] == "boolean" + assert "enum" in mysql["internal_tmp_mem_storage_engine"] + assert "pattern" in mysql["sql_mode"] + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_mysql_with_engine_config(mysql_db_with_engine_config): + db = mysql_db_with_engine_config + actual_config = db.engine_config.mysql + expected_config = make_full_mysql_engine_config().mysql.__dict__ + + for key, expected_value in expected_config.items(): + actual_value = getattr(actual_config, key) + assert ( + actual_value == expected_value + ), f"{key} mismatch: expected {expected_value}, got {actual_value}" + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_update_mysql_engine_config( + test_linode_client, mysql_db_with_engine_config +): + db = mysql_db_with_engine_config + + db.updates.day_of_week = 2 + db.engine_config = MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions(connect_timeout=50), + binlog_retention_period=880, + ) + + db.save() + + wait_for_condition( + 30, + 300, + get_sql_db_status, + test_linode_client, + db.id, + "active", + ) + + database = test_linode_client.load(MySQLDatabase, db.id) + + assert database.updates.day_of_week == 2 + assert database.engine_config.mysql.connect_timeout == 50 + assert database.engine_config.binlog_retention_period == 880 + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_list_mysql_engine_config( + test_linode_client, mysql_db_with_engine_config +): + dbs = test_linode_client.database.mysql_instances() + + db_ids = [db.id for db in dbs] + + assert mysql_db_with_engine_config.id in db_ids + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_mysql_engine_config( + test_linode_client, mysql_db_with_engine_config +): + db = test_linode_client.load(MySQLDatabase, mysql_db_with_engine_config.id) + + assert isinstance(db, MySQLDatabase) + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_mysql_db_nullable_field(test_linode_client): + client = test_linode_client + label = get_test_label(5) + "-sqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_mysql_engine_config_w_nullable_field(), + ) + + assert db.engine_config.mysql.innodb_ft_server_stopword_table is None + + send_request_when_resource_available(300, db.delete) + + +# POSTGRESQL +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_postgres_config(test_linode_client): + config = test_linode_client.database.postgresql_config_options() + + # Top-level keys and structure + assert "pg" in config + + assert "pg_stat_monitor_enable" in config + assert config["pg_stat_monitor_enable"]["type"] == "boolean" + + assert "shared_buffers_percentage" in config + assert config["shared_buffers_percentage"]["type"] == "number" + assert config["shared_buffers_percentage"]["minimum"] >= 1 + + assert "work_mem" in config + assert config["work_mem"]["type"] == "integer" + assert "minimum" in config["work_mem"] + + pg = config["pg"] + + # postgres valid fields + expected_keys = [ + "autovacuum_analyze_scale_factor", + "autovacuum_analyze_threshold", + "autovacuum_max_workers", + "autovacuum_naptime", + "autovacuum_vacuum_cost_delay", + "autovacuum_vacuum_cost_limit", + "autovacuum_vacuum_scale_factor", + "autovacuum_vacuum_threshold", + "bgwriter_delay", + "bgwriter_flush_after", + "bgwriter_lru_maxpages", + "bgwriter_lru_multiplier", + "deadlock_timeout", + "default_toast_compression", + "idle_in_transaction_session_timeout", + "jit", + "max_files_per_process", + "max_locks_per_transaction", + "max_logical_replication_workers", + "max_parallel_workers", + "max_parallel_workers_per_gather", + "max_pred_locks_per_transaction", + "max_replication_slots", + "max_slot_wal_keep_size", + "max_stack_depth", + "max_standby_archive_delay", + "max_standby_streaming_delay", + "max_wal_senders", + "max_worker_processes", + "password_encryption", + "pg_partman_bgw.interval", + "pg_partman_bgw.role", + "pg_stat_monitor.pgsm_enable_query_plan", + "pg_stat_monitor.pgsm_max_buckets", + "pg_stat_statements.track", + "temp_file_limit", + "timezone", + "track_activity_query_size", + "track_commit_timestamp", + "track_functions", + "track_io_timing", + "wal_sender_timeout", + "wal_writer_delay", + ] + + # Assert all valid fields are present + for key in expected_keys: + assert key in pg, f"{key} not found in postgresql config" + + assert pg["autovacuum_analyze_scale_factor"]["type"] == "number" + assert pg["autovacuum_analyze_threshold"]["type"] == "integer" + assert pg["autovacuum_max_workers"]["requires_restart"] is True + assert pg["default_toast_compression"]["enum"] == ["lz4", "pglz"] + assert pg["jit"]["type"] == "boolean" + assert "enum" in pg["password_encryption"] + assert "pattern" in pg["pg_partman_bgw.role"] + assert pg["pg_stat_monitor.pgsm_enable_query_plan"]["type"] == "boolean" + assert pg["pg_stat_monitor.pgsm_max_buckets"]["requires_restart"] is True + assert pg["pg_stat_statements.track"]["enum"] == ["all", "top", "none"] + assert pg["track_commit_timestamp"]["enum"] == ["off", "on"] + assert pg["track_functions"]["enum"] == ["all", "pl", "none"] + assert pg["track_io_timing"]["enum"] == ["off", "on"] + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_postgres_with_engine_config( + test_linode_client, postgres_db_with_engine_config +): + db = postgres_db_with_engine_config + actual_config = db.engine_config.pg + expected_config = make_full_postgres_engine_config().pg.__dict__ + + for key, expected_value in expected_config.items(): + actual_value = getattr(actual_config, key, None) + assert ( + actual_value is None or actual_value == expected_value + ), f"{key} mismatch: expected {expected_value}, got {actual_value}" + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_update_postgres_engine_config( + test_linode_client, postgres_db_with_engine_config +): + db = postgres_db_with_engine_config + + db.updates.day_of_week = 2 + db.engine_config = PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_threshold=70, deadlock_timeout=2000 + ), + shared_buffers_percentage=25.0, + ) + + db.save() + + wait_for_condition( + 30, + 300, + get_postgres_db_status, + test_linode_client, + db.id, + "active", + ) + + database = test_linode_client.load(PostgreSQLDatabase, db.id) + + assert database.updates.day_of_week == 2 + assert database.engine_config.pg.autovacuum_analyze_threshold == 70 + assert database.engine_config.pg.deadlock_timeout == 2000 + assert database.engine_config.shared_buffers_percentage == 25.0 + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_pg13_with_lz4_error(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "postgresql/13") + dbtype = "g6-standard-1" + + try: + client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + default_toast_compression="lz4" + ), + work_mem=4, + ), + ) + except ApiError as e: + assert "An error occurred" in str(e.json) + assert e.status == 500 + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_list_postgres_engine_config( + test_linode_client, postgres_db_with_engine_config +): + dbs = test_linode_client.database.postgresql_instances() + + db_ids = [db.id for db in dbs] + + assert postgres_db_with_engine_config.id in db_ids + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_get_postgres_engine_config( + test_linode_client, postgres_db_with_engine_config +): + db = test_linode_client.load( + PostgreSQLDatabase, postgres_db_with_engine_config.id + ) + + assert isinstance(db, PostgreSQLDatabase) + + +@pytest.mark.skipif( + os.getenv("RUN_DB_TESTS", "").strip().lower() not in {"yes", "true"}, + reason="RUN_DB_TESTS environment variable must be set to 'yes' or 'true' (case insensitive)", +) +def test_create_postgres_db_password_encryption_default_md5(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = "postgresql/17" + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + engine_config=make_postgres_engine_config_w_password_encryption_null(), + ) + + assert db.engine_config.pg.password_encryption == "md5" + + send_request_when_resource_available(300, db.delete) diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py index 09d842b77..d1939aec7 100644 --- a/test/unit/groups/database_test.py +++ b/test/unit/groups/database_test.py @@ -132,6 +132,1194 @@ def test_create(self): self.assertEqual(m.call_data["type"], "g6-standard-1") self.assertEqual(m.call_data["cluster_size"], 3) + def test_mysql_config_options(self): + """ + Test that MySQL configuration options can be retrieved + """ + + config = self.client.database.mysql_config_options() + + self.assertEqual( + "The number of seconds that the mysqld server waits for a connect packet before responding with Bad handshake", + config["mysql"]["connect_timeout"]["description"], + ) + self.assertEqual(10, config["mysql"]["connect_timeout"]["example"]) + self.assertEqual(3600, config["mysql"]["connect_timeout"]["maximum"]) + self.assertEqual(2, config["mysql"]["connect_timeout"]["minimum"]) + self.assertFalse(config["mysql"]["connect_timeout"]["requires_restart"]) + self.assertEqual("integer", config["mysql"]["connect_timeout"]["type"]) + + self.assertEqual( + "Default server time zone as an offset from UTC (from -12:00 to +12:00), a time zone name, or 'SYSTEM' to use the MySQL server default.", + config["mysql"]["default_time_zone"]["description"], + ) + self.assertEqual( + "+03:00", config["mysql"]["default_time_zone"]["example"] + ) + self.assertEqual(100, config["mysql"]["default_time_zone"]["maxLength"]) + self.assertEqual(2, config["mysql"]["default_time_zone"]["minLength"]) + self.assertEqual( + "^([-+][\\d:]*|[\\w/]*)$", + config["mysql"]["default_time_zone"]["pattern"], + ) + self.assertFalse( + config["mysql"]["default_time_zone"]["requires_restart"] + ) + self.assertEqual("string", config["mysql"]["default_time_zone"]["type"]) + + self.assertEqual( + "The maximum permitted result length in bytes for the GROUP_CONCAT() function.", + config["mysql"]["group_concat_max_len"]["description"], + ) + self.assertEqual( + 1024, config["mysql"]["group_concat_max_len"]["example"] + ) + self.assertEqual( + 18446744073709551600, + config["mysql"]["group_concat_max_len"]["maximum"], + ) + self.assertEqual(4, config["mysql"]["group_concat_max_len"]["minimum"]) + self.assertFalse( + config["mysql"]["group_concat_max_len"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["group_concat_max_len"]["type"] + ) + + self.assertEqual( + "The time, in seconds, before cached statistics expire", + config["mysql"]["information_schema_stats_expiry"]["description"], + ) + self.assertEqual( + 86400, config["mysql"]["information_schema_stats_expiry"]["example"] + ) + self.assertEqual( + 31536000, + config["mysql"]["information_schema_stats_expiry"]["maximum"], + ) + self.assertEqual( + 900, config["mysql"]["information_schema_stats_expiry"]["minimum"] + ) + self.assertFalse( + config["mysql"]["information_schema_stats_expiry"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["mysql"]["information_schema_stats_expiry"]["type"], + ) + + self.assertEqual( + "Maximum size for the InnoDB change buffer, as a percentage of the total size of the buffer pool. Default is 25", + config["mysql"]["innodb_change_buffer_max_size"]["description"], + ) + self.assertEqual( + 30, config["mysql"]["innodb_change_buffer_max_size"]["example"] + ) + self.assertEqual( + 50, config["mysql"]["innodb_change_buffer_max_size"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_change_buffer_max_size"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_change_buffer_max_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_change_buffer_max_size"]["type"] + ) + + self.assertEqual( + "Specifies whether flushing a page from the InnoDB buffer pool also flushes other dirty pages in the same extent (default is 1): 0 - dirty pages in the same extent are not flushed, 1 - flush contiguous dirty pages in the same extent, 2 - flush dirty pages in the same extent", + config["mysql"]["innodb_flush_neighbors"]["description"], + ) + self.assertEqual( + 0, config["mysql"]["innodb_flush_neighbors"]["example"] + ) + self.assertEqual( + 2, config["mysql"]["innodb_flush_neighbors"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_flush_neighbors"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_flush_neighbors"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_flush_neighbors"]["type"] + ) + + self.assertEqual( + "Minimum length of words that are stored in an InnoDB FULLTEXT index. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_ft_min_token_size"]["description"], + ) + self.assertEqual( + 3, config["mysql"]["innodb_ft_min_token_size"]["example"] + ) + self.assertEqual( + 16, config["mysql"]["innodb_ft_min_token_size"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_ft_min_token_size"]["minimum"] + ) + self.assertTrue( + config["mysql"]["innodb_ft_min_token_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_ft_min_token_size"]["type"] + ) + + self.assertEqual( + "This option is used to specify your own InnoDB FULLTEXT index stopword list for all InnoDB tables.", + config["mysql"]["innodb_ft_server_stopword_table"]["description"], + ) + self.assertEqual( + "db_name/table_name", + config["mysql"]["innodb_ft_server_stopword_table"]["example"], + ) + self.assertEqual( + 1024, + config["mysql"]["innodb_ft_server_stopword_table"]["maxLength"], + ) + self.assertEqual( + "^.+/.+$", + config["mysql"]["innodb_ft_server_stopword_table"]["pattern"], + ) + self.assertFalse( + config["mysql"]["innodb_ft_server_stopword_table"][ + "requires_restart" + ] + ) + self.assertEqual( + ["null", "string"], + config["mysql"]["innodb_ft_server_stopword_table"]["type"], + ) + + self.assertEqual( + "The length of time in seconds an InnoDB transaction waits for a row lock before giving up. Default is 120.", + config["mysql"]["innodb_lock_wait_timeout"]["description"], + ) + self.assertEqual( + 50, config["mysql"]["innodb_lock_wait_timeout"]["example"] + ) + self.assertEqual( + 3600, config["mysql"]["innodb_lock_wait_timeout"]["maximum"] + ) + self.assertEqual( + 1, config["mysql"]["innodb_lock_wait_timeout"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_lock_wait_timeout"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_lock_wait_timeout"]["type"] + ) + + self.assertEqual( + "The size in bytes of the buffer that InnoDB uses to write to the log files on disk.", + config["mysql"]["innodb_log_buffer_size"]["description"], + ) + self.assertEqual( + 16777216, config["mysql"]["innodb_log_buffer_size"]["example"] + ) + self.assertEqual( + 4294967295, config["mysql"]["innodb_log_buffer_size"]["maximum"] + ) + self.assertEqual( + 1048576, config["mysql"]["innodb_log_buffer_size"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_log_buffer_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_log_buffer_size"]["type"] + ) + + self.assertEqual( + "The upper limit in bytes on the size of the temporary log files used during online DDL operations for InnoDB tables.", + config["mysql"]["innodb_online_alter_log_max_size"]["description"], + ) + self.assertEqual( + 134217728, + config["mysql"]["innodb_online_alter_log_max_size"]["example"], + ) + self.assertEqual( + 1099511627776, + config["mysql"]["innodb_online_alter_log_max_size"]["maximum"], + ) + self.assertEqual( + 65536, + config["mysql"]["innodb_online_alter_log_max_size"]["minimum"], + ) + self.assertFalse( + config["mysql"]["innodb_online_alter_log_max_size"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["mysql"]["innodb_online_alter_log_max_size"]["type"], + ) + + self.assertEqual( + "The number of I/O threads for read operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_read_io_threads"]["description"], + ) + self.assertEqual( + 10, config["mysql"]["innodb_read_io_threads"]["example"] + ) + self.assertEqual( + 64, config["mysql"]["innodb_read_io_threads"]["maximum"] + ) + self.assertEqual( + 1, config["mysql"]["innodb_read_io_threads"]["minimum"] + ) + self.assertTrue( + config["mysql"]["innodb_read_io_threads"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_read_io_threads"]["type"] + ) + + self.assertEqual( + "When enabled a transaction timeout causes InnoDB to abort and roll back the entire transaction. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_rollback_on_timeout"]["description"], + ) + self.assertTrue( + config["mysql"]["innodb_rollback_on_timeout"]["example"] + ) + self.assertTrue( + config["mysql"]["innodb_rollback_on_timeout"]["requires_restart"] + ) + self.assertEqual( + "boolean", config["mysql"]["innodb_rollback_on_timeout"]["type"] + ) + + self.assertEqual( + "Defines the maximum number of threads permitted inside of InnoDB. Default is 0 (infinite concurrency - no limit)", + config["mysql"]["innodb_thread_concurrency"]["description"], + ) + self.assertEqual( + 10, config["mysql"]["innodb_thread_concurrency"]["example"] + ) + self.assertEqual( + 1000, config["mysql"]["innodb_thread_concurrency"]["maximum"] + ) + self.assertEqual( + 0, config["mysql"]["innodb_thread_concurrency"]["minimum"] + ) + self.assertFalse( + config["mysql"]["innodb_thread_concurrency"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_thread_concurrency"]["type"] + ) + + self.assertEqual( + "The number of I/O threads for write operations in InnoDB. Default is 4. Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["innodb_write_io_threads"]["description"], + ) + self.assertEqual( + 10, config["mysql"]["innodb_write_io_threads"]["example"] + ) + self.assertEqual( + 64, config["mysql"]["innodb_write_io_threads"]["maximum"] + ) + self.assertEqual( + 1, config["mysql"]["innodb_write_io_threads"]["minimum"] + ) + self.assertTrue( + config["mysql"]["innodb_write_io_threads"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["innodb_write_io_threads"]["type"] + ) + + self.assertEqual( + "The number of seconds the server waits for activity on an interactive connection before closing it.", + config["mysql"]["interactive_timeout"]["description"], + ) + self.assertEqual( + 3600, config["mysql"]["interactive_timeout"]["example"] + ) + self.assertEqual( + 604800, config["mysql"]["interactive_timeout"]["maximum"] + ) + self.assertEqual(30, config["mysql"]["interactive_timeout"]["minimum"]) + self.assertFalse( + config["mysql"]["interactive_timeout"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["interactive_timeout"]["type"] + ) + + self.assertEqual( + "The storage engine for in-memory internal temporary tables.", + config["mysql"]["internal_tmp_mem_storage_engine"]["description"], + ) + self.assertEqual( + "TempTable", + config["mysql"]["internal_tmp_mem_storage_engine"]["example"], + ) + self.assertEqual( + ["TempTable", "MEMORY"], + config["mysql"]["internal_tmp_mem_storage_engine"]["enum"], + ) + self.assertFalse( + config["mysql"]["internal_tmp_mem_storage_engine"][ + "requires_restart" + ] + ) + self.assertEqual( + "string", config["mysql"]["internal_tmp_mem_storage_engine"]["type"] + ) + + self.assertEqual( + "Size of the largest message in bytes that can be received by the server. Default is 67108864 (64M)", + config["mysql"]["max_allowed_packet"]["description"], + ) + self.assertEqual( + 67108864, config["mysql"]["max_allowed_packet"]["example"] + ) + self.assertEqual( + 1073741824, config["mysql"]["max_allowed_packet"]["maximum"] + ) + self.assertEqual( + 102400, config["mysql"]["max_allowed_packet"]["minimum"] + ) + self.assertFalse( + config["mysql"]["max_allowed_packet"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["max_allowed_packet"]["type"] + ) + + self.assertEqual( + "Limits the size of internal in-memory tables. Also set tmp_table_size. Default is 16777216 (16M)", + config["mysql"]["max_heap_table_size"]["description"], + ) + self.assertEqual( + 16777216, config["mysql"]["max_heap_table_size"]["example"] + ) + self.assertEqual( + 1073741824, config["mysql"]["max_heap_table_size"]["maximum"] + ) + self.assertEqual( + 1048576, config["mysql"]["max_heap_table_size"]["minimum"] + ) + self.assertFalse( + config["mysql"]["max_heap_table_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["max_heap_table_size"]["type"] + ) + + self.assertEqual( + "Start sizes of connection buffer and result buffer. Default is 16384 (16K). Changing this parameter will lead to a restart of the MySQL service.", + config["mysql"]["net_buffer_length"]["description"], + ) + self.assertEqual(16384, config["mysql"]["net_buffer_length"]["example"]) + self.assertEqual( + 1048576, config["mysql"]["net_buffer_length"]["maximum"] + ) + self.assertEqual(1024, config["mysql"]["net_buffer_length"]["minimum"]) + self.assertTrue( + config["mysql"]["net_buffer_length"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["net_buffer_length"]["type"] + ) + + self.assertEqual( + "The number of seconds to wait for more data from a connection before aborting the read.", + config["mysql"]["net_read_timeout"]["description"], + ) + self.assertEqual(30, config["mysql"]["net_read_timeout"]["example"]) + self.assertEqual(3600, config["mysql"]["net_read_timeout"]["maximum"]) + self.assertEqual(1, config["mysql"]["net_read_timeout"]["minimum"]) + self.assertFalse( + config["mysql"]["net_read_timeout"]["requires_restart"] + ) + self.assertEqual("integer", config["mysql"]["net_read_timeout"]["type"]) + + self.assertEqual( + "The number of seconds to wait for a block to be written to a connection before aborting the write.", + config["mysql"]["net_write_timeout"]["description"], + ) + self.assertEqual(30, config["mysql"]["net_write_timeout"]["example"]) + self.assertEqual(3600, config["mysql"]["net_write_timeout"]["maximum"]) + self.assertEqual(1, config["mysql"]["net_write_timeout"]["minimum"]) + self.assertFalse( + config["mysql"]["net_write_timeout"]["requires_restart"] + ) + self.assertEqual( + "integer", config["mysql"]["net_write_timeout"]["type"] + ) + + self.assertEqual( + "Sort buffer size in bytes for ORDER BY optimization. Default is 262144 (256K)", + config["mysql"]["sort_buffer_size"]["description"], + ) + self.assertEqual(262144, config["mysql"]["sort_buffer_size"]["example"]) + self.assertEqual( + 1073741824, config["mysql"]["sort_buffer_size"]["maximum"] + ) + self.assertEqual(32768, config["mysql"]["sort_buffer_size"]["minimum"]) + self.assertFalse( + config["mysql"]["sort_buffer_size"]["requires_restart"] + ) + self.assertEqual("integer", config["mysql"]["sort_buffer_size"]["type"]) + + self.assertEqual( + "Global SQL mode. Set to empty to use MySQL server defaults. When creating a new service and not setting this field Akamai default SQL mode (strict, SQL standard compliant) will be assigned.", + config["mysql"]["sql_mode"]["description"], + ) + self.assertEqual( + "ANSI,TRADITIONAL", config["mysql"]["sql_mode"]["example"] + ) + self.assertEqual(1024, config["mysql"]["sql_mode"]["maxLength"]) + self.assertEqual( + "^[A-Z_]*(,[A-Z_]+)*$", config["mysql"]["sql_mode"]["pattern"] + ) + self.assertFalse(config["mysql"]["sql_mode"]["requires_restart"]) + self.assertEqual("string", config["mysql"]["sql_mode"]["type"]) + + self.assertEqual( + "Require primary key to be defined for new tables or old tables modified with ALTER TABLE and fail if missing. It is recommended to always have primary keys because various functionality may break if any large table is missing them.", + config["mysql"]["sql_require_primary_key"]["description"], + ) + self.assertTrue(config["mysql"]["sql_require_primary_key"]["example"]) + self.assertFalse( + config["mysql"]["sql_require_primary_key"]["requires_restart"] + ) + self.assertEqual( + "boolean", config["mysql"]["sql_require_primary_key"]["type"] + ) + + self.assertEqual( + "Limits the size of internal in-memory tables. Also set max_heap_table_size. Default is 16777216 (16M)", + config["mysql"]["tmp_table_size"]["description"], + ) + self.assertEqual(16777216, config["mysql"]["tmp_table_size"]["example"]) + self.assertEqual( + 1073741824, config["mysql"]["tmp_table_size"]["maximum"] + ) + self.assertEqual(1048576, config["mysql"]["tmp_table_size"]["minimum"]) + self.assertFalse(config["mysql"]["tmp_table_size"]["requires_restart"]) + self.assertEqual("integer", config["mysql"]["tmp_table_size"]["type"]) + + self.assertEqual( + "The number of seconds the server waits for activity on a noninteractive connection before closing it.", + config["mysql"]["wait_timeout"]["description"], + ) + self.assertEqual(28800, config["mysql"]["wait_timeout"]["example"]) + self.assertEqual(2147483, config["mysql"]["wait_timeout"]["maximum"]) + self.assertEqual(1, config["mysql"]["wait_timeout"]["minimum"]) + self.assertFalse(config["mysql"]["wait_timeout"]["requires_restart"]) + self.assertEqual("integer", config["mysql"]["wait_timeout"]["type"]) + + self.assertEqual( + "The minimum amount of time in seconds to keep binlog entries before deletion. This may be extended for services that require binlog entries for longer than the default for example if using the MySQL Debezium Kafka connector.", + config["binlog_retention_period"]["description"], + ) + self.assertEqual(600, config["binlog_retention_period"]["example"]) + self.assertEqual(86400, config["binlog_retention_period"]["maximum"]) + self.assertEqual(600, config["binlog_retention_period"]["minimum"]) + self.assertFalse(config["binlog_retention_period"]["requires_restart"]) + self.assertEqual("integer", config["binlog_retention_period"]["type"]) + + def test_postgresql_config_options(self): + """ + Test that PostgreSQL configuration options can be retrieved + """ + + config = self.client.database.postgresql_config_options() + + self.assertEqual( + "Specifies a fraction of the table size to add to autovacuum_analyze_threshold when " + + "deciding whether to trigger an ANALYZE. The default is 0.2 (20% of table size)", + config["pg"]["autovacuum_analyze_scale_factor"]["description"], + ) + self.assertEqual( + 1.0, config["pg"]["autovacuum_analyze_scale_factor"]["maximum"] + ) + self.assertEqual( + 0.0, config["pg"]["autovacuum_analyze_scale_factor"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_analyze_scale_factor"]["requires_restart"] + ) + self.assertEqual( + "number", config["pg"]["autovacuum_analyze_scale_factor"]["type"] + ) + + self.assertEqual( + "Specifies the minimum number of inserted, updated or deleted tuples needed to trigger an ANALYZE in any one table. The default is 50 tuples.", + config["pg"]["autovacuum_analyze_threshold"]["description"], + ) + self.assertEqual( + 2147483647, config["pg"]["autovacuum_analyze_threshold"]["maximum"] + ) + self.assertEqual( + 0, config["pg"]["autovacuum_analyze_threshold"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_analyze_threshold"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_analyze_threshold"]["type"] + ) + + self.assertEqual( + "Specifies the maximum number of autovacuum processes (other than the autovacuum launcher) that may be running at any one time. The default is three. This parameter can only be set at server start.", + config["pg"]["autovacuum_max_workers"]["description"], + ) + self.assertEqual(20, config["pg"]["autovacuum_max_workers"]["maximum"]) + self.assertEqual(1, config["pg"]["autovacuum_max_workers"]["minimum"]) + self.assertFalse( + config["pg"]["autovacuum_max_workers"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_max_workers"]["type"] + ) + + self.assertEqual( + "Specifies the minimum delay between autovacuum runs on any given database. The delay is measured in seconds, and the default is one minute", + config["pg"]["autovacuum_naptime"]["description"], + ) + self.assertEqual(86400, config["pg"]["autovacuum_naptime"]["maximum"]) + self.assertEqual(1, config["pg"]["autovacuum_naptime"]["minimum"]) + self.assertFalse(config["pg"]["autovacuum_naptime"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["autovacuum_naptime"]["type"]) + + self.assertEqual( + "Specifies the cost delay value that will be used in automatic VACUUM operations. If -1 is specified, the regular vacuum_cost_delay value will be used. The default value is 20 milliseconds", + config["pg"]["autovacuum_vacuum_cost_delay"]["description"], + ) + self.assertEqual( + 100, config["pg"]["autovacuum_vacuum_cost_delay"]["maximum"] + ) + self.assertEqual( + -1, config["pg"]["autovacuum_vacuum_cost_delay"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_cost_delay"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_vacuum_cost_delay"]["type"] + ) + + self.assertEqual( + "Specifies the cost limit value that will be used in automatic VACUUM operations. If -1 is specified (which is the default), the regular vacuum_cost_limit value will be used.", + config["pg"]["autovacuum_vacuum_cost_limit"]["description"], + ) + self.assertEqual( + 10000, config["pg"]["autovacuum_vacuum_cost_limit"]["maximum"] + ) + self.assertEqual( + -1, config["pg"]["autovacuum_vacuum_cost_limit"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_cost_limit"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_vacuum_cost_limit"]["type"] + ) + + self.assertEqual( + "Specifies a fraction of the table size to add to autovacuum_vacuum_threshold when deciding whether to trigger a VACUUM. The default is 0.2 (20% of table size)", + config["pg"]["autovacuum_vacuum_scale_factor"]["description"], + ) + self.assertEqual( + 1.0, config["pg"]["autovacuum_vacuum_scale_factor"]["maximum"] + ) + self.assertEqual( + 0.0, config["pg"]["autovacuum_vacuum_scale_factor"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_scale_factor"]["requires_restart"] + ) + self.assertEqual( + "number", config["pg"]["autovacuum_vacuum_scale_factor"]["type"] + ) + + self.assertEqual( + "Specifies the minimum number of updated or deleted tuples needed to trigger a VACUUM in any one table. The default is 50 tuples", + config["pg"]["autovacuum_vacuum_threshold"]["description"], + ) + self.assertEqual( + 2147483647, config["pg"]["autovacuum_vacuum_threshold"]["maximum"] + ) + self.assertEqual( + 0, config["pg"]["autovacuum_vacuum_threshold"]["minimum"] + ) + self.assertFalse( + config["pg"]["autovacuum_vacuum_threshold"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["autovacuum_vacuum_threshold"]["type"] + ) + + self.assertEqual( + "Specifies the delay between activity rounds for the background writer in milliseconds. Default is 200.", + config["pg"]["bgwriter_delay"]["description"], + ) + self.assertEqual(200, config["pg"]["bgwriter_delay"]["example"]) + self.assertEqual(10000, config["pg"]["bgwriter_delay"]["maximum"]) + self.assertEqual(10, config["pg"]["bgwriter_delay"]["minimum"]) + self.assertFalse(config["pg"]["bgwriter_delay"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["bgwriter_delay"]["type"]) + + self.assertEqual( + "Whenever more than bgwriter_flush_after bytes have been written by the background writer, attempt to force the OS to issue these writes to the underlying storage. Specified in kilobytes, default is 512. Setting of 0 disables forced writeback.", + config["pg"]["bgwriter_flush_after"]["description"], + ) + self.assertEqual(512, config["pg"]["bgwriter_flush_after"]["example"]) + self.assertEqual(2048, config["pg"]["bgwriter_flush_after"]["maximum"]) + self.assertEqual(0, config["pg"]["bgwriter_flush_after"]["minimum"]) + self.assertFalse( + config["pg"]["bgwriter_flush_after"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["bgwriter_flush_after"]["type"] + ) + + self.assertEqual( + "In each round, no more than this many buffers will be written by the background writer. Setting this to zero disables background writing. Default is 100.", + config["pg"]["bgwriter_lru_maxpages"]["description"], + ) + self.assertEqual(100, config["pg"]["bgwriter_lru_maxpages"]["example"]) + self.assertEqual( + 1073741823, config["pg"]["bgwriter_lru_maxpages"]["maximum"] + ) + self.assertEqual(0, config["pg"]["bgwriter_lru_maxpages"]["minimum"]) + self.assertFalse( + config["pg"]["bgwriter_lru_maxpages"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["bgwriter_lru_maxpages"]["type"] + ) + + self.assertEqual( + "The average recent need for new buffers is multiplied by bgwriter_lru_multiplier to arrive at an estimate of the number that will be needed during the next round, (up to bgwriter_lru_maxpages). 1.0 represents a “just in time” policy of writing exactly the number of buffers predicted to be needed. Larger values provide some cushion against spikes in demand, while smaller values intentionally leave writes to be done by server processes. The default is 2.0.", + config["pg"]["bgwriter_lru_multiplier"]["description"], + ) + self.assertEqual( + 2.0, config["pg"]["bgwriter_lru_multiplier"]["example"] + ) + self.assertEqual( + 10.0, config["pg"]["bgwriter_lru_multiplier"]["maximum"] + ) + self.assertEqual( + 0.0, config["pg"]["bgwriter_lru_multiplier"]["minimum"] + ) + self.assertFalse( + config["pg"]["bgwriter_lru_multiplier"]["requires_restart"] + ) + self.assertEqual( + "number", config["pg"]["bgwriter_lru_multiplier"]["type"] + ) + + self.assertEqual( + "This is the amount of time, in milliseconds, to wait on a lock before checking to see if there is a deadlock condition.", + config["pg"]["deadlock_timeout"]["description"], + ) + self.assertEqual(1000, config["pg"]["deadlock_timeout"]["example"]) + self.assertEqual(1800000, config["pg"]["deadlock_timeout"]["maximum"]) + self.assertEqual(500, config["pg"]["deadlock_timeout"]["minimum"]) + self.assertFalse(config["pg"]["deadlock_timeout"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["deadlock_timeout"]["type"]) + + self.assertEqual( + "Specifies the default TOAST compression method for values of compressible columns (the default is lz4).", + config["pg"]["default_toast_compression"]["description"], + ) + self.assertEqual( + ["lz4", "pglz"], config["pg"]["default_toast_compression"]["enum"] + ) + self.assertEqual( + "lz4", config["pg"]["default_toast_compression"]["example"] + ) + self.assertFalse( + config["pg"]["default_toast_compression"]["requires_restart"] + ) + self.assertEqual( + "string", config["pg"]["default_toast_compression"]["type"] + ) + + self.assertEqual( + "Time out sessions with open transactions after this number of milliseconds", + config["pg"]["idle_in_transaction_session_timeout"]["description"], + ) + self.assertEqual( + 604800000, + config["pg"]["idle_in_transaction_session_timeout"]["maximum"], + ) + self.assertEqual( + 0, config["pg"]["idle_in_transaction_session_timeout"]["minimum"] + ) + self.assertFalse( + config["pg"]["idle_in_transaction_session_timeout"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["pg"]["idle_in_transaction_session_timeout"]["type"], + ) + + self.assertEqual( + "Controls system-wide use of Just-in-Time Compilation (JIT).", + config["pg"]["jit"]["description"], + ) + self.assertTrue(config["pg"]["jit"]["example"]) + self.assertFalse(config["pg"]["jit"]["requires_restart"]) + self.assertEqual("boolean", config["pg"]["jit"]["type"]) + + self.assertEqual( + "PostgreSQL maximum number of files that can be open per process", + config["pg"]["max_files_per_process"]["description"], + ) + self.assertEqual(4096, config["pg"]["max_files_per_process"]["maximum"]) + self.assertEqual(1000, config["pg"]["max_files_per_process"]["minimum"]) + self.assertFalse( + config["pg"]["max_files_per_process"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_files_per_process"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum locks per transaction", + config["pg"]["max_locks_per_transaction"]["description"], + ) + self.assertEqual( + 6400, config["pg"]["max_locks_per_transaction"]["maximum"] + ) + self.assertEqual( + 64, config["pg"]["max_locks_per_transaction"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_locks_per_transaction"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_locks_per_transaction"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum logical replication workers (taken from the pool of max_parallel_workers)", + config["pg"]["max_logical_replication_workers"]["description"], + ) + self.assertEqual( + 64, config["pg"]["max_logical_replication_workers"]["maximum"] + ) + self.assertEqual( + 4, config["pg"]["max_logical_replication_workers"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_logical_replication_workers"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_logical_replication_workers"]["type"] + ) + + self.assertEqual( + "Sets the maximum number of workers that the system can support for parallel queries", + config["pg"]["max_parallel_workers"]["description"], + ) + self.assertEqual(96, config["pg"]["max_parallel_workers"]["maximum"]) + self.assertEqual(0, config["pg"]["max_parallel_workers"]["minimum"]) + self.assertFalse( + config["pg"]["max_parallel_workers"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_parallel_workers"]["type"] + ) + + self.assertEqual( + "Sets the maximum number of workers that can be started by a single Gather or Gather Merge node", + config["pg"]["max_parallel_workers_per_gather"]["description"], + ) + self.assertEqual( + 96, config["pg"]["max_parallel_workers_per_gather"]["maximum"] + ) + self.assertEqual( + 0, config["pg"]["max_parallel_workers_per_gather"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_parallel_workers_per_gather"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_parallel_workers_per_gather"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum predicate locks per transaction", + config["pg"]["max_pred_locks_per_transaction"]["description"], + ) + self.assertEqual( + 5120, config["pg"]["max_pred_locks_per_transaction"]["maximum"] + ) + self.assertEqual( + 64, config["pg"]["max_pred_locks_per_transaction"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_pred_locks_per_transaction"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_pred_locks_per_transaction"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum replication slots", + config["pg"]["max_replication_slots"]["description"], + ) + self.assertEqual(64, config["pg"]["max_replication_slots"]["maximum"]) + self.assertEqual(8, config["pg"]["max_replication_slots"]["minimum"]) + self.assertFalse( + config["pg"]["max_replication_slots"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_replication_slots"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum WAL size (MB) reserved for replication slots. Default is -1 (unlimited). wal_keep_size minimum WAL size setting takes precedence over this.", + config["pg"]["max_slot_wal_keep_size"]["description"], + ) + self.assertEqual( + 2147483647, config["pg"]["max_slot_wal_keep_size"]["maximum"] + ) + self.assertEqual(-1, config["pg"]["max_slot_wal_keep_size"]["minimum"]) + self.assertFalse( + config["pg"]["max_slot_wal_keep_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_slot_wal_keep_size"]["type"] + ) + + self.assertEqual( + "Maximum depth of the stack in bytes", + config["pg"]["max_stack_depth"]["description"], + ) + self.assertEqual(6291456, config["pg"]["max_stack_depth"]["maximum"]) + self.assertEqual(2097152, config["pg"]["max_stack_depth"]["minimum"]) + self.assertFalse(config["pg"]["max_stack_depth"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["max_stack_depth"]["type"]) + + self.assertEqual( + "Max standby archive delay in milliseconds", + config["pg"]["max_standby_archive_delay"]["description"], + ) + self.assertEqual( + 43200000, config["pg"]["max_standby_archive_delay"]["maximum"] + ) + self.assertEqual( + 1, config["pg"]["max_standby_archive_delay"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_standby_archive_delay"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_standby_archive_delay"]["type"] + ) + + self.assertEqual( + "Max standby streaming delay in milliseconds", + config["pg"]["max_standby_streaming_delay"]["description"], + ) + self.assertEqual( + 43200000, config["pg"]["max_standby_streaming_delay"]["maximum"] + ) + self.assertEqual( + 1, config["pg"]["max_standby_streaming_delay"]["minimum"] + ) + self.assertFalse( + config["pg"]["max_standby_streaming_delay"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_standby_streaming_delay"]["type"] + ) + + self.assertEqual( + "PostgreSQL maximum WAL senders", + config["pg"]["max_wal_senders"]["description"], + ) + self.assertEqual(64, config["pg"]["max_wal_senders"]["maximum"]) + self.assertEqual(20, config["pg"]["max_wal_senders"]["minimum"]) + self.assertFalse(config["pg"]["max_wal_senders"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["max_wal_senders"]["type"]) + + self.assertEqual( + "Sets the maximum number of background processes that the system can support", + config["pg"]["max_worker_processes"]["description"], + ) + self.assertEqual(96, config["pg"]["max_worker_processes"]["maximum"]) + self.assertEqual(8, config["pg"]["max_worker_processes"]["minimum"]) + self.assertFalse( + config["pg"]["max_worker_processes"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["max_worker_processes"]["type"] + ) + + self.assertEqual( + "Chooses the algorithm for encrypting passwords.", + config["pg"]["password_encryption"]["description"], + ) + self.assertEqual( + ["md5", "scram-sha-256"], + config["pg"]["password_encryption"]["enum"], + ) + self.assertEqual( + "scram-sha-256", config["pg"]["password_encryption"]["example"] + ) + self.assertFalse( + config["pg"]["password_encryption"]["requires_restart"] + ) + self.assertEqual( + ["string", "null"], config["pg"]["password_encryption"]["type"] + ) + + self.assertEqual( + "Sets the time interval to run pg_partman's scheduled tasks", + config["pg"]["pg_partman_bgw.interval"]["description"], + ) + self.assertEqual( + 3600, config["pg"]["pg_partman_bgw.interval"]["example"] + ) + self.assertEqual( + 604800, config["pg"]["pg_partman_bgw.interval"]["maximum"] + ) + self.assertEqual( + 3600, config["pg"]["pg_partman_bgw.interval"]["minimum"] + ) + self.assertFalse( + config["pg"]["pg_partman_bgw.interval"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["pg_partman_bgw.interval"]["type"] + ) + + self.assertEqual( + "Controls which role to use for pg_partman's scheduled background tasks.", + config["pg"]["pg_partman_bgw.role"]["description"], + ) + self.assertEqual( + "myrolename", config["pg"]["pg_partman_bgw.role"]["example"] + ) + self.assertEqual(64, config["pg"]["pg_partman_bgw.role"]["maxLength"]) + self.assertEqual( + "^[_A-Za-z0-9][-._A-Za-z0-9]{0,63}$", + config["pg"]["pg_partman_bgw.role"]["pattern"], + ) + self.assertFalse( + config["pg"]["pg_partman_bgw.role"]["requires_restart"] + ) + self.assertEqual("string", config["pg"]["pg_partman_bgw.role"]["type"]) + + self.assertEqual( + "Enables or disables query plan monitoring", + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"][ + "description" + ], + ) + self.assertFalse( + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"]["example"] + ) + self.assertFalse( + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"][ + "requires_restart" + ] + ) + self.assertEqual( + "boolean", + config["pg"]["pg_stat_monitor.pgsm_enable_query_plan"]["type"], + ) + + self.assertEqual( + "Sets the maximum number of buckets", + config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["description"], + ) + self.assertEqual( + 10, config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["example"] + ) + self.assertEqual( + 10, config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["maximum"] + ) + self.assertEqual( + 1, config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["minimum"] + ) + self.assertFalse( + config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["pg_stat_monitor.pgsm_max_buckets"]["type"] + ) + + self.assertEqual( + "Controls which statements are counted. Specify top to track top-level statements (those issued directly by clients), all to also track nested statements (such as statements invoked within functions), or none to disable statement statistics collection. The default value is top.", + config["pg"]["pg_stat_statements.track"]["description"], + ) + self.assertEqual( + ["all", "top", "none"], + config["pg"]["pg_stat_statements.track"]["enum"], + ) + self.assertFalse( + config["pg"]["pg_stat_statements.track"]["requires_restart"] + ) + self.assertEqual( + ["string"], config["pg"]["pg_stat_statements.track"]["type"] + ) + + self.assertEqual( + "PostgreSQL temporary file limit in KiB, -1 for unlimited", + config["pg"]["temp_file_limit"]["description"], + ) + self.assertEqual(5000000, config["pg"]["temp_file_limit"]["example"]) + self.assertEqual(2147483647, config["pg"]["temp_file_limit"]["maximum"]) + self.assertEqual(-1, config["pg"]["temp_file_limit"]["minimum"]) + self.assertFalse(config["pg"]["temp_file_limit"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["temp_file_limit"]["type"]) + + self.assertEqual( + "PostgreSQL service timezone", + config["pg"]["timezone"]["description"], + ) + self.assertEqual("Europe/Helsinki", config["pg"]["timezone"]["example"]) + self.assertEqual(64, config["pg"]["timezone"]["maxLength"]) + self.assertEqual("^[\\w/]*$", config["pg"]["timezone"]["pattern"]) + self.assertFalse(config["pg"]["timezone"]["requires_restart"]) + self.assertEqual("string", config["pg"]["timezone"]["type"]) + + self.assertEqual( + "Specifies the number of bytes reserved to track the currently executing command for each active session.", + config["pg"]["track_activity_query_size"]["description"], + ) + self.assertEqual( + 1024, config["pg"]["track_activity_query_size"]["example"] + ) + self.assertEqual( + 10240, config["pg"]["track_activity_query_size"]["maximum"] + ) + self.assertEqual( + 1024, config["pg"]["track_activity_query_size"]["minimum"] + ) + self.assertFalse( + config["pg"]["track_activity_query_size"]["requires_restart"] + ) + self.assertEqual( + "integer", config["pg"]["track_activity_query_size"]["type"] + ) + + self.assertEqual( + "Record commit time of transactions.", + config["pg"]["track_commit_timestamp"]["description"], + ) + self.assertEqual( + "off", config["pg"]["track_commit_timestamp"]["example"] + ) + self.assertEqual( + ["off", "on"], config["pg"]["track_commit_timestamp"]["enum"] + ) + self.assertFalse( + config["pg"]["track_commit_timestamp"]["requires_restart"] + ) + self.assertEqual( + "string", config["pg"]["track_commit_timestamp"]["type"] + ) + + self.assertEqual( + "Enables tracking of function call counts and time used.", + config["pg"]["track_functions"]["description"], + ) + self.assertEqual( + ["all", "pl", "none"], config["pg"]["track_functions"]["enum"] + ) + self.assertFalse(config["pg"]["track_functions"]["requires_restart"]) + self.assertEqual("string", config["pg"]["track_functions"]["type"]) + + self.assertEqual( + "Enables timing of database I/O calls. This parameter is off by default, because it will repeatedly query the operating system for the current time, which may cause significant overhead on some platforms.", + config["pg"]["track_io_timing"]["description"], + ) + self.assertEqual("off", config["pg"]["track_io_timing"]["example"]) + self.assertEqual(["off", "on"], config["pg"]["track_io_timing"]["enum"]) + self.assertFalse(config["pg"]["track_io_timing"]["requires_restart"]) + self.assertEqual("string", config["pg"]["track_io_timing"]["type"]) + + self.assertEqual( + "Terminate replication connections that are inactive for longer than this amount of time, in milliseconds. Setting this value to zero disables the timeout.", + config["pg"]["wal_sender_timeout"]["description"], + ) + self.assertEqual(60000, config["pg"]["wal_sender_timeout"]["example"]) + self.assertFalse(config["pg"]["wal_sender_timeout"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["wal_sender_timeout"]["type"]) + + self.assertEqual( + "WAL flush interval in milliseconds. Note that setting this value to lower than the default 200ms may negatively impact performance", + config["pg"]["wal_writer_delay"]["description"], + ) + self.assertEqual(50, config["pg"]["wal_writer_delay"]["example"]) + self.assertEqual(200, config["pg"]["wal_writer_delay"]["maximum"]) + self.assertEqual(10, config["pg"]["wal_writer_delay"]["minimum"]) + self.assertFalse(config["pg"]["wal_writer_delay"]["requires_restart"]) + self.assertEqual("integer", config["pg"]["wal_writer_delay"]["type"]) + + self.assertEqual( + "Enable the pg_stat_monitor extension. Enabling this extension will cause the cluster to be restarted. When this extension is enabled, pg_stat_statements results for utility commands are unreliable", + config["pg_stat_monitor_enable"]["description"], + ) + self.assertTrue(config["pg_stat_monitor_enable"]["requires_restart"]) + self.assertEqual("boolean", config["pg_stat_monitor_enable"]["type"]) + + self.assertEqual( + "Number of seconds of master unavailability before triggering database failover to standby", + config["pglookout"]["max_failover_replication_time_lag"][ + "description" + ], + ) + self.assertEqual( + int(9223372036854775000), + config["pglookout"]["max_failover_replication_time_lag"]["maximum"], + ) + self.assertEqual( + int(10), + config["pglookout"]["max_failover_replication_time_lag"]["minimum"], + ) + self.assertFalse( + config["pglookout"]["max_failover_replication_time_lag"][ + "requires_restart" + ] + ) + self.assertEqual( + "integer", + config["pglookout"]["max_failover_replication_time_lag"]["type"], + ) + + self.assertEqual( + "Percentage of total RAM that the database server uses for shared memory buffers. Valid range is 20-60 (float), which corresponds to 20% - 60%. This setting adjusts the shared_buffers configuration value.", + config["shared_buffers_percentage"]["description"], + ) + self.assertEqual(41.5, config["shared_buffers_percentage"]["example"]) + self.assertEqual(60.0, config["shared_buffers_percentage"]["maximum"]) + self.assertEqual(20.0, config["shared_buffers_percentage"]["minimum"]) + self.assertFalse( + config["shared_buffers_percentage"]["requires_restart"] + ) + self.assertEqual("number", config["shared_buffers_percentage"]["type"]) + + self.assertEqual( + "Sets the maximum amount of memory to be used by a query operation (such as a sort or hash table) before writing to temporary disk files, in MB. Default is 1MB + 0.075% of total RAM (up to 32MB).", + config["work_mem"]["description"], + ) + self.assertEqual(4, config["work_mem"]["example"]) + self.assertEqual(1024, config["work_mem"]["maximum"]) + self.assertEqual(1, config["work_mem"]["minimum"]) + self.assertFalse(config["work_mem"]["requires_restart"]) + self.assertEqual("integer", config["work_mem"]["type"]) + class PostgreSQLDatabaseTest(ClientBaseCase): """ diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 51c7de4cd..8605e43c5 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -1,7 +1,13 @@ import logging from test.unit.base import ClientBaseCase -from linode_api4 import PostgreSQLDatabase +from linode_api4 import ( + MySQLDatabaseConfigMySQLOptions, + MySQLDatabaseConfigOptions, + PostgreSQLDatabase, + PostgreSQLDatabaseConfigOptions, + PostgreSQLDatabaseConfigPGOptions, +) from linode_api4.objects import MySQLDatabase logger = logging.getLogger(__name__) @@ -103,6 +109,59 @@ def test_get_instances(self): self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) self.assertEqual(dbs[0].version, "8.0.26") + self.assertEqual(dbs[0].engine_config.binlog_retention_period, 600) + self.assertEqual(dbs[0].engine_config.mysql.connect_timeout, 10) + self.assertEqual(dbs[0].engine_config.mysql.default_time_zone, "+03:00") + self.assertEqual(dbs[0].engine_config.mysql.group_concat_max_len, 1024) + self.assertEqual( + dbs[0].engine_config.mysql.information_schema_stats_expiry, 86400 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_change_buffer_max_size, 30 + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_flush_neighbors, 0) + self.assertEqual(dbs[0].engine_config.mysql.innodb_ft_min_token_size, 3) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_ft_server_stopword_table, + "db_name/table_name", + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_lock_wait_timeout, 50 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_log_buffer_size, 16777216 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_online_alter_log_max_size, + 134217728, + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_read_io_threads, 10) + self.assertTrue(dbs[0].engine_config.mysql.innodb_rollback_on_timeout) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_thread_concurrency, 10 + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_write_io_threads, 10) + self.assertEqual(dbs[0].engine_config.mysql.interactive_timeout, 3600) + self.assertEqual( + dbs[0].engine_config.mysql.internal_tmp_mem_storage_engine, + "TempTable", + ) + self.assertEqual( + dbs[0].engine_config.mysql.max_allowed_packet, 67108864 + ) + self.assertEqual( + dbs[0].engine_config.mysql.max_heap_table_size, 16777216 + ) + self.assertEqual(dbs[0].engine_config.mysql.net_buffer_length, 16384) + self.assertEqual(dbs[0].engine_config.mysql.net_read_timeout, 30) + self.assertEqual(dbs[0].engine_config.mysql.net_write_timeout, 30) + self.assertEqual(dbs[0].engine_config.mysql.sort_buffer_size, 262144) + self.assertEqual( + dbs[0].engine_config.mysql.sql_mode, "ANSI,TRADITIONAL" + ) + self.assertTrue(dbs[0].engine_config.mysql.sql_require_primary_key) + self.assertEqual(dbs[0].engine_config.mysql.tmp_table_size, 16777216) + self.assertEqual(dbs[0].engine_config.mysql.wait_timeout, 28800) def test_create(self): """ @@ -121,6 +180,12 @@ def test_create(self): "mysql/8.0.26", "g6-standard-1", cluster_size=3, + engine_config=MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions( + connect_timeout=20 + ), + binlog_retention_period=200, + ), ) except Exception as e: logger.warning( @@ -134,6 +199,12 @@ def test_create(self): self.assertEqual(m.call_data["engine"], "mysql/8.0.26") self.assertEqual(m.call_data["type"], "g6-standard-1") self.assertEqual(m.call_data["cluster_size"], 3) + self.assertEqual( + m.call_data["engine_config"]["mysql"]["connect_timeout"], 20 + ) + self.assertEqual( + m.call_data["engine_config"]["binlog_retention_period"], 200 + ) def test_update(self): """ @@ -148,6 +219,10 @@ def test_update(self): db.updates.day_of_week = 2 db.allow_list = new_allow_list db.label = "cool" + db.engine_config = MySQLDatabaseConfigOptions( + mysql=MySQLDatabaseConfigMySQLOptions(connect_timeout=20), + binlog_retention_period=200, + ) db.save() @@ -156,6 +231,12 @@ def test_update(self): self.assertEqual(m.call_data["label"], "cool") self.assertEqual(m.call_data["updates"]["day_of_week"], 2) self.assertEqual(m.call_data["allow_list"], new_allow_list) + self.assertEqual( + m.call_data["engine_config"]["mysql"]["connect_timeout"], 20 + ) + self.assertEqual( + m.call_data["engine_config"]["binlog_retention_period"], 200 + ) def test_list_backups(self): """ @@ -321,6 +402,97 @@ def test_get_instances(self): self.assertEqual(dbs[0].updates.duration, 3) self.assertEqual(dbs[0].version, "13.2") + print(dbs[0].engine_config.pg.__dict__) + + self.assertTrue(dbs[0].engine_config.pg_stat_monitor_enable) + self.assertEqual( + dbs[0].engine_config.pglookout.max_failover_replication_time_lag, + 1000, + ) + self.assertEqual(dbs[0].engine_config.shared_buffers_percentage, 41.5) + self.assertEqual(dbs[0].engine_config.work_mem, 4) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_analyze_scale_factor, 0.5 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_analyze_threshold, 100 + ) + self.assertEqual(dbs[0].engine_config.pg.autovacuum_max_workers, 10) + self.assertEqual(dbs[0].engine_config.pg.autovacuum_naptime, 100) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_cost_delay, 50 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_cost_limit, 100 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_scale_factor, 0.5 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_threshold, 100 + ) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_delay, 200) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_flush_after, 512) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_maxpages, 100) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_multiplier, 2.0) + self.assertEqual(dbs[0].engine_config.pg.deadlock_timeout, 1000) + self.assertEqual( + dbs[0].engine_config.pg.default_toast_compression, "lz4" + ) + self.assertEqual( + dbs[0].engine_config.pg.idle_in_transaction_session_timeout, 100 + ) + self.assertTrue(dbs[0].engine_config.pg.jit) + self.assertEqual(dbs[0].engine_config.pg.max_files_per_process, 100) + self.assertEqual(dbs[0].engine_config.pg.max_locks_per_transaction, 100) + self.assertEqual( + dbs[0].engine_config.pg.max_logical_replication_workers, 32 + ) + self.assertEqual(dbs[0].engine_config.pg.max_parallel_workers, 64) + self.assertEqual( + dbs[0].engine_config.pg.max_parallel_workers_per_gather, 64 + ) + self.assertEqual( + dbs[0].engine_config.pg.max_pred_locks_per_transaction, 1000 + ) + self.assertEqual(dbs[0].engine_config.pg.max_replication_slots, 32) + self.assertEqual(dbs[0].engine_config.pg.max_slot_wal_keep_size, 100) + self.assertEqual(dbs[0].engine_config.pg.max_stack_depth, 3507152) + self.assertEqual( + dbs[0].engine_config.pg.max_standby_archive_delay, 1000 + ) + self.assertEqual( + dbs[0].engine_config.pg.max_standby_streaming_delay, 1000 + ) + self.assertEqual(dbs[0].engine_config.pg.max_wal_senders, 32) + self.assertEqual(dbs[0].engine_config.pg.max_worker_processes, 64) + self.assertEqual( + dbs[0].engine_config.pg.password_encryption, "scram-sha-256" + ) + self.assertEqual(dbs[0].engine_config.pg.pg_partman_bgw_interval, 3600) + self.assertEqual( + dbs[0].engine_config.pg.pg_partman_bgw_role, "myrolename" + ) + self.assertFalse( + dbs[0].engine_config.pg.pg_stat_monitor_pgsm_enable_query_plan + ) + self.assertEqual( + dbs[0].engine_config.pg.pg_stat_monitor_pgsm_max_buckets, 10 + ) + self.assertEqual( + dbs[0].engine_config.pg.pg_stat_statements_track, "top" + ) + self.assertEqual(dbs[0].engine_config.pg.temp_file_limit, 5000000) + self.assertEqual(dbs[0].engine_config.pg.timezone, "Europe/Helsinki") + self.assertEqual( + dbs[0].engine_config.pg.track_activity_query_size, 1024 + ) + self.assertEqual(dbs[0].engine_config.pg.track_commit_timestamp, "off") + self.assertEqual(dbs[0].engine_config.pg.track_functions, "all") + self.assertEqual(dbs[0].engine_config.pg.track_io_timing, "off") + self.assertEqual(dbs[0].engine_config.pg.wal_sender_timeout, 60000) + self.assertEqual(dbs[0].engine_config.pg.wal_writer_delay, 50) + def test_create(self): """ Test that PostgreSQL databases can be created @@ -336,6 +508,17 @@ def test_create(self): "postgresql/13.2", "g6-standard-1", cluster_size=3, + engine_config=PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_scale_factor=0.5, + pg_partman_bgw_interval=3600, + pg_partman_bgw_role="myrolename", + pg_stat_monitor_pgsm_enable_query_plan=False, + pg_stat_monitor_pgsm_max_buckets=10, + pg_stat_statements_track="top", + ), + work_mem=4, + ), ) except Exception: pass @@ -347,6 +530,37 @@ def test_create(self): self.assertEqual(m.call_data["engine"], "postgresql/13.2") self.assertEqual(m.call_data["type"], "g6-standard-1") self.assertEqual(m.call_data["cluster_size"], 3) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "autovacuum_analyze_scale_factor" + ], + 0.5, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"]["pg_partman_bgw.interval"], + 3600, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"]["pg_partman_bgw.role"], + "myrolename", + ) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "pg_stat_monitor.pgsm_enable_query_plan" + ], + False, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "pg_stat_monitor.pgsm_max_buckets" + ], + 10, + ) + self.assertEqual( + m.call_data["engine_config"]["pg"]["pg_stat_statements.track"], + "top", + ) + self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) def test_update(self): """ @@ -361,6 +575,12 @@ def test_update(self): db.updates.day_of_week = 2 db.allow_list = new_allow_list db.label = "cool" + db.engine_config = PostgreSQLDatabaseConfigOptions( + pg=PostgreSQLDatabaseConfigPGOptions( + autovacuum_analyze_scale_factor=0.5 + ), + work_mem=4, + ) db.save() @@ -369,6 +589,13 @@ def test_update(self): self.assertEqual(m.call_data["label"], "cool") self.assertEqual(m.call_data["updates"]["day_of_week"], 2) self.assertEqual(m.call_data["allow_list"], new_allow_list) + self.assertEqual( + m.call_data["engine_config"]["pg"][ + "autovacuum_analyze_scale_factor" + ], + 0.5, + ) + self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) def test_list_backups(self): """ From ff344ddd4f1a96332669b23b241b7a08b2a96be4 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 23 May 2025 20:46:03 -0400 Subject: [PATCH 307/379] Deprecate `Event.mark_read()` (#551) --- linode_api4/objects/account.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index c7318d871..836f41522 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -3,6 +3,7 @@ from datetime import datetime import requests +from deprecated import deprecated from linode_api4.errors import ApiError, UnexpectedResponseError from linode_api4.objects import DATE_FORMAT, Volume @@ -305,6 +306,12 @@ def volume(self): return Volume(self._client, self.entity.id) return None + @deprecated( + reason="`mark_read` API is deprecated. Use the 'mark_seen' " + "API instead. Please note that the `mark_seen` API functions " + "differently and will mark all events up to and including the " + "referenced event-id as 'seen' rather than individual events.", + ) def mark_read(self): """ Marks a single Event as read. From 8ea18d1c760441847a13974b6298c8984582b91e Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 27 May 2025 09:07:35 -0400 Subject: [PATCH 308/379] Added missing doc links (#556) --- linode_api4/groups/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index fec3df929..9de02ac35 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -73,7 +73,7 @@ def mysql_config_options(self): """ Returns a detailed list of all the configuration options for MySQL Databases. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-config :returns: The JSON configuration options for MySQL Databases. """ @@ -83,7 +83,7 @@ def postgresql_config_options(self): """ Returns a detailed list of all the configuration options for PostgreSQL Databases. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-config :returns: The JSON configuration options for PostgreSQL Databases. """ From fc620df16898ae3ec43316f5ee6ae8f9b95a7459 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 27 May 2025 11:13:21 -0400 Subject: [PATCH 309/379] project: UDP NodeBalancers (#549) * Add support for Nodebalancers UDP (#494) * Implemented changes for NodeBalancers UDP * Added unit tests * Fix lint * Fixed issue with cipher_suite in save * Lint * Addressed PR comments * Removed overriden _serialize method * Drop residual prints * Implement _serialize(...) override in NodeBalancerConfig (#555) * Add LA notice --------- Co-authored-by: Jacob Riddle <87780794+jriddle-linode@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Co-authored-by: Youjung Kim <126618609+ykim-1@users.noreply.github.com> Co-authored-by: Erik Zilber Co-authored-by: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> --- linode_api4/objects/nodebalancer.py | 19 +++ .../nodebalancers_123456_configs.json | 28 ++++- ...ebalancers_123456_configs_65432_nodes.json | 12 +- .../models/nodebalancer/test_nodebalancer.py | 110 +++++++++++++++++- test/unit/objects/nodebalancers_test.py | 20 ++++ 5 files changed, 186 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index 840d5b965..f02dda269 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -77,6 +77,8 @@ class NodeBalancerConfig(DerivedBase): The configuration information for a single port of this NodeBalancer. API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-config + + NOTE: UDP NodeBalancer Configs may not currently be available to all users. """ api_endpoint = "/nodebalancers/{nodebalancer_id}/configs/{id}" @@ -97,6 +99,8 @@ class NodeBalancerConfig(DerivedBase): "check_path": Property(mutable=True), "check_body": Property(mutable=True), "check_passive": Property(mutable=True), + "udp_check_port": Property(mutable=True), + "udp_session_timeout": Property(), "ssl_cert": Property(mutable=True), "ssl_key": Property(mutable=True), "ssl_commonname": Property(), @@ -106,6 +110,20 @@ class NodeBalancerConfig(DerivedBase): "proxy_protocol": Property(mutable=True), } + def _serialize(self, is_put: bool = False): + """ + This override removes the `cipher_suite` field from the PUT request + body on calls to save(...) for UDP configs, which is rejected by + the API. + """ + + result = super()._serialize(is_put) + + if is_put and result["protocol"] == "udp" and "cipher_suite" in result: + del result["cipher_suite"] + + return result + @property def nodes(self): """ @@ -233,6 +251,7 @@ class NodeBalancer(Base): "configs": Property(derived_class=NodeBalancerConfig), "transfer": Property(), "tags": Property(mutable=True, unordered=True), + "client_udp_sess_throttle": Property(mutable=True), } # create derived objects diff --git a/test/fixtures/nodebalancers_123456_configs.json b/test/fixtures/nodebalancers_123456_configs.json index f12f1345f..cab9fb981 100644 --- a/test/fixtures/nodebalancers_123456_configs.json +++ b/test/fixtures/nodebalancers_123456_configs.json @@ -24,9 +24,35 @@ "protocol": "http", "ssl_fingerprint": "", "proxy_protocol": "none" + }, + { + "check": "connection", + "check_attempts": 2, + "stickiness": "table", + "check_interval": 5, + "check_body": "", + "id": 65431, + "check_passive": true, + "algorithm": "roundrobin", + "check_timeout": 3, + "check_path": "/", + "ssl_cert": null, + "ssl_commonname": "", + "port": 80, + "nodebalancer_id": 123456, + "cipher_suite": "none", + "ssl_key": null, + "nodes_status": { + "up": 0, + "down": 0 + }, + "protocol": "udp", + "ssl_fingerprint": "", + "proxy_protocol": "none", + "udp_check_port": 12345 } ], - "results": 1, + "results": 2, "page": 1, "pages": 1 } diff --git a/test/fixtures/nodebalancers_123456_configs_65432_nodes.json b/test/fixtures/nodebalancers_123456_configs_65432_nodes.json index 658edbb50..f8ffd9edf 100644 --- a/test/fixtures/nodebalancers_123456_configs_65432_nodes.json +++ b/test/fixtures/nodebalancers_123456_configs_65432_nodes.json @@ -9,9 +9,19 @@ "mode": "accept", "config_id": 54321, "nodebalancer_id": 123456 + }, + { + "id": 12345, + "address": "192.168.210.120", + "label": "node12345", + "status": "UP", + "weight": 50, + "mode": "none", + "config_id": 123456, + "nodebalancer_id": 123456 } ], "pages": 1, "page": 1, - "results": 1 + "results": 2 } diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 21f4d0322..df07de215 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -9,7 +9,7 @@ import pytest -from linode_api4 import ApiError, LinodeClient +from linode_api4 import ApiError, LinodeClient, NodeBalancer from linode_api4.objects import ( NodeBalancerConfig, NodeBalancerNode, @@ -64,6 +64,55 @@ def create_nb_config(test_linode_client, e2e_test_firewall): nb.delete() +@pytest.fixture(scope="session") +def create_nb_config_with_udp(test_linode_client, e2e_test_firewall): + client = test_linode_client + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, label=label, firewall=e2e_test_firewall.id + ) + + config = nb.config_create(protocol="udp", udp_check_port=1234) + + yield config + + config.delete() + nb.delete() + + +@pytest.fixture(scope="session") +def create_nb(test_linode_client, e2e_test_firewall): + client = test_linode_client + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, label=label, firewall=e2e_test_firewall.id + ) + + yield nb + + nb.delete() + + +def test_create_nb(test_linode_client, e2e_test_firewall): + client = test_linode_client + label = get_test_label(8) + + nb = client.nodebalancer_create( + region=TEST_REGION, + label=label, + firewall=e2e_test_firewall.id, + client_udp_sess_throttle=5, + ) + + assert TEST_REGION, nb.region + assert label == nb.label + assert 5 == nb.client_udp_sess_throttle + + nb.delete() + + def test_get_nodebalancer_config(test_linode_client, create_nb_config): config = test_linode_client.load( NodeBalancerConfig, @@ -72,6 +121,65 @@ def test_get_nodebalancer_config(test_linode_client, create_nb_config): ) +def test_get_nb_config_with_udp(test_linode_client, create_nb_config_with_udp): + config = test_linode_client.load( + NodeBalancerConfig, + create_nb_config_with_udp.id, + create_nb_config_with_udp.nodebalancer_id, + ) + + assert "udp" == config.protocol + assert 1234 == config.udp_check_port + assert 16 == config.udp_session_timeout + + +def test_update_nb_config(test_linode_client, create_nb_config_with_udp): + config = test_linode_client.load( + NodeBalancerConfig, + create_nb_config_with_udp.id, + create_nb_config_with_udp.nodebalancer_id, + ) + + config.udp_check_port = 4321 + config.save() + + config_updated = test_linode_client.load( + NodeBalancerConfig, + create_nb_config_with_udp.id, + create_nb_config_with_udp.nodebalancer_id, + ) + + assert 4321 == config_updated.udp_check_port + + +def test_get_nb(test_linode_client, create_nb): + nb = test_linode_client.load( + NodeBalancer, + create_nb.id, + ) + + assert nb.id == create_nb.id + + +def test_update_nb(test_linode_client, create_nb): + nb = test_linode_client.load( + NodeBalancer, + create_nb.id, + ) + + nb.label = "ThisNewLabel" + nb.client_udp_sess_throttle = 5 + nb.save() + + nb_updated = test_linode_client.load( + NodeBalancer, + create_nb.id, + ) + + assert "ThisNewLabel" == nb_updated.label + assert 5 == nb_updated.client_udp_sess_throttle + + @pytest.mark.smoke def test_create_nb_node( test_linode_client, create_nb_config, linode_with_private_ip diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index 05f0ad7de..ed0f0c320 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -42,6 +42,23 @@ def test_get_config(self): self.assertEqual(config.ssl_fingerprint, "") self.assertEqual(config.proxy_protocol, "none") + config_udp = NodeBalancerConfig(self.client, 65431, 123456) + self.assertEqual(config_udp.protocol, "udp") + self.assertEqual(config_udp.udp_check_port, 12345) + + def test_update_config_udp(self): + """ + Tests that a config with a protocol of udp can be updated and that cipher suite is properly excluded in save() + """ + with self.mock_put("nodebalancers/123456/configs/65431") as m: + config = self.client.load(NodeBalancerConfig, 65431, 123456) + config.udp_check_port = 54321 + config.save() + + self.assertEqual(m.call_url, "/nodebalancers/123456/configs/65431") + self.assertEqual(m.call_data["udp_check_port"], 54321) + self.assertNotIn("cipher_suite", m.call_data) + class NodeBalancerNodeTest(ClientBaseCase): """ @@ -66,6 +83,9 @@ def test_get_node(self): self.assertEqual(node.config_id, 65432) self.assertEqual(node.nodebalancer_id, 123456) + node_udp = NodeBalancerNode(self.client, 12345, (65432, 123456)) + self.assertEqual(node_udp.mode, "none") + def test_create_node(self): """ Tests that a node can be created From 0b1b2af4f64ac0e4557029b82ba966b394b5c2ca Mon Sep 17 00:00:00 2001 From: pmajali Date: Mon, 2 Jun 2025 23:37:26 +0530 Subject: [PATCH 310/379] Adding SDK changes for ACLP APIs (#528) * adding monitor APIs * updating doc * updating tests * updating lint errors * Updating method name Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> * updating code with review comments * sorting imports * updating with make format changes * updating with review comments * review updates * updating docstring * updating func names * PR comments * review comments * adding __all__ module * updating unittest and entity_id to any --------- Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/monitor.py | 153 +++++++++++++++ linode_api4/linode_client.py | 3 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/monitor.py | 180 ++++++++++++++++++ test/fixtures/monitor_dashboards.json | 37 ++++ test/fixtures/monitor_dashboards_1.json | 30 +++ test/fixtures/monitor_services.json | 11 ++ test/fixtures/monitor_services_dbaas.json | 11 ++ .../monitor_services_dbaas_dashboards.json | 37 ++++ ...tor_services_dbaas_metric-definitions.json | 55 ++++++ .../monitor_services_dbaas_token.json | 3 + .../monitor_services_linode_token.json | 3 + .../models/monitor/test_monitor.py | 109 +++++++++++ test/unit/objects/monitor_test.py | 123 ++++++++++++ 15 files changed, 757 insertions(+) create mode 100644 linode_api4/groups/monitor.py create mode 100644 linode_api4/objects/monitor.py create mode 100644 test/fixtures/monitor_dashboards.json create mode 100644 test/fixtures/monitor_dashboards_1.json create mode 100644 test/fixtures/monitor_services.json create mode 100644 test/fixtures/monitor_services_dbaas.json create mode 100644 test/fixtures/monitor_services_dbaas_dashboards.json create mode 100644 test/fixtures/monitor_services_dbaas_metric-definitions.json create mode 100644 test/fixtures/monitor_services_dbaas_token.json create mode 100644 test/fixtures/monitor_services_linode_token.json create mode 100644 test/integration/models/monitor/test_monitor.py create mode 100644 test/unit/objects/monitor_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index e50eeab66..3842042ad 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 .monitor import * from .networking import * from .nodebalancer import * from .object_storage import * diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py new file mode 100644 index 000000000..908b4e819 --- /dev/null +++ b/linode_api4/groups/monitor.py @@ -0,0 +1,153 @@ +__all__ = [ + "MonitorGroup", +] +from typing import Any, Optional + +from linode_api4 import ( + PaginatedList, +) +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import ( + MonitorDashboard, + MonitorMetricsDefinition, + MonitorService, + MonitorServiceToken, +) + + +class MonitorGroup(Group): + """ + Encapsulates Monitor-related methods of the :any:`LinodeClient`. + + This group contains all features beneath the `/monitor` group in the API v4. + """ + + def dashboards( + self, *filters, service_type: Optional[str] = None + ) -> PaginatedList: + """ + Returns a list of dashboards. If `service_type` is provided, it fetches dashboards + for the specific service type. If None, it fetches all dashboards. + + dashboards = client.monitor.dashboards() + dashboard = client.load(MonitorDashboard, 1) + dashboards_by_service = client.monitor.dashboards(service_type="dbaas") + + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: + - All Dashboards: https://techdocs.akamai.com/linode-api/reference/get-dashboards-all + - Dashboards by Service: https://techdocs.akamai.com/linode-api/reference/get-dashboards + + :param service_type: The service type to get dashboards for. + :type service_type: Optional[str] + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Dashboards. + :rtype: PaginatedList of Dashboard + """ + endpoint = ( + f"/monitor/services/{service_type}/dashboards" + if service_type + else "/monitor/dashboards" + ) + + return self.client._get_and_filter( + MonitorDashboard, + *filters, + endpoint=endpoint, + ) + + def services( + self, *filters, service_type: Optional[str] = None + ) -> list[MonitorService]: + """ + Lists services supported by ACLP. + supported_services = client.monitor.services() + service_details = client.monitor.services(service_type="dbaas") + + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services-for-service-type + + :param service_type: The service type to get details for. + :type service_type: Optional[str] + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: Lists monitor services by a given service_type + :rtype: PaginatedList of the Services + """ + endpoint = ( + f"/monitor/services/{service_type}" + if service_type + else "/monitor/services" + ) + return self.client._get_and_filter( + MonitorService, + *filters, + endpoint=endpoint, + ) + + def metric_definitions( + self, service_type: str, *filters + ) -> list[MonitorMetricsDefinition]: + """ + Returns metrics for a specific service type. + + metrics = client.monitor.list_metric_definitions(service_type="dbaas") + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-information + + :param service_type: The service type to get metrics for. + :type service_type: str + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: Returns a List of metrics for a service + :rtype: PaginatedList of metrics + """ + return self.client._get_and_filter( + MonitorMetricsDefinition, + *filters, + endpoint=f"/monitor/services/{service_type}/metric-definitions", + ) + + def create_token( + self, service_type: str, entity_ids: list[Any] + ) -> MonitorServiceToken: + """ + Returns a JWE Token for a specific service type. + token = client.monitor.create_token(service_type="dbaas", entity_ids=[1234]) + + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-get-token + + :param service_type: The service type to create token for. + :type service_type: str + :param entity_ids: The list of entity IDs for which the token is valid. + :type entity_ids: any + + :returns: Returns a token for a service + :rtype: str + """ + + params = {"entity_ids": entity_ids} + + result = self.client.post( + f"/monitor/services/{service_type}/token", data=params + ) + + if "token" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating token!", json=result + ) + return MonitorServiceToken(token=result["token"]) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 19e6f3900..e71f1563e 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -19,6 +19,7 @@ LinodeGroup, LKEGroup, LongviewGroup, + MonitorGroup, NetworkingGroup, NodeBalancerGroup, ObjectStorageGroup, @@ -201,6 +202,8 @@ def __init__( #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. self.placement = PlacementAPIGroup(self) + self.monitor = MonitorGroup(self) + @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index b13fac51a..7f1542d2a 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -21,3 +21,4 @@ from .vpc import * from .beta import * from .placement import * +from .monitor import * diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py new file mode 100644 index 000000000..f518e641d --- /dev/null +++ b/linode_api4/objects/monitor.py @@ -0,0 +1,180 @@ +__all__ = [ + "MonitorDashboard", + "MonitorMetricsDefinition", + "MonitorService", + "MonitorServiceToken", +] +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects import Base, JSONObject, Property, StrEnum + + +class AggregateFunction(StrEnum): + """ + Enum for supported aggregate functions. + """ + + min = "min" + max = "max" + avg = "avg" + sum = "sum" + count = "count" + rate = "rate" + increase = "increase" + last = "last" + + +class ChartType(StrEnum): + """ + Enum for supported chart types. + """ + + line = "line" + area = "area" + + +class ServiceType(StrEnum): + """ + Enum for supported service types. + """ + + dbaas = "dbaas" + linode = "linode" + lke = "lke" + vpc = "vpc" + nodebalancer = "nodebalancer" + firewall = "firewall" + object_storage = "object_storage" + aclb = "aclb" + + +class MetricType(StrEnum): + """ + Enum for supported metric type + """ + + gauge = "gauge" + counter = "counter" + histogram = "histogram" + summary = "summary" + + +class MetricUnit(StrEnum): + """ + Enum for supported metric units. + """ + + COUNT = "count" + PERCENT = "percent" + BYTE = "byte" + SECOND = "second" + BITS_PER_SECOND = "bits_per_second" + MILLISECOND = "millisecond" + KB = "KB" + MB = "MB" + GB = "GB" + RATE = "rate" + BYTES_PER_SECOND = "bytes_per_second" + PERCENTILE = "percentile" + RATIO = "ratio" + OPS_PER_SECOND = "ops_per_second" + IOPS = "iops" + + +class DashboardType(StrEnum): + """ + Enum for supported dashboard types. + """ + + standard = "standard" + custom = "custom" + + +@dataclass +class DashboardWidget(JSONObject): + """ + Represents a single widget in the widgets list. + """ + + metric: str = "" + unit: MetricUnit = "" + label: str = "" + color: str = "" + size: int = 0 + chart_type: ChartType = "" + y_label: str = "" + aggregate_function: AggregateFunction = "" + + +@dataclass +class Dimension(JSONObject): + """ + Represents a single dimension in the dimensions list. + """ + + dimension_label: Optional[str] = None + label: Optional[str] = None + values: Optional[List[str]] = None + + +@dataclass +class MonitorMetricsDefinition(JSONObject): + """ + Represents a single metric definition in the metrics definition list. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-information + """ + + metric: str = "" + label: str = "" + metric_type: MetricType = "" + unit: MetricUnit = "" + scrape_interval: int = 0 + is_alertable: bool = False + dimensions: Optional[List[Dimension]] = None + available_aggregate_functions: List[AggregateFunction] = field( + default_factory=list + ) + + +class MonitorDashboard(Base): + """ + Dashboard details. + + List dashboards: https://techdocs.akamai.com/linode-api/get-dashboards-all + """ + + api_endpoint = "/monitor/dashboards/{id}" + properties = { + "id": Property(identifier=True), + "created": Property(is_datetime=True), + "label": Property(), + "service_type": Property(ServiceType), + "type": Property(DashboardType), + "widgets": Property(List[DashboardWidget]), + "updated": Property(is_datetime=True), + } + + +@dataclass +class MonitorService(JSONObject): + """ + Represents a single service type. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services + + """ + + service_type: ServiceType = "" + label: str = "" + + +@dataclass +class MonitorServiceToken(JSONObject): + """ + A token for the requested service_type. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-get-token + """ + + token: str = "" diff --git a/test/fixtures/monitor_dashboards.json b/test/fixtures/monitor_dashboards.json new file mode 100644 index 000000000..42de92b55 --- /dev/null +++ b/test/fixtures/monitor_dashboards.json @@ -0,0 +1,37 @@ +{ + "data": [ + { + "created": "2024-10-10T05:01:58", + "id": 1, + "label": "Resource Usage", + "service_type": "dbaas", + "type": "standard", + "updated": "2024-10-10T05:01:58", + "widgets": [ + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "CPU Usage", + "metric": "cpu_usage", + "size": 12, + "unit": "%", + "y_label": "cpu_usage" + }, + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "Disk I/O Write", + "metric": "write_iops", + "size": 6, + "unit": "IOPS", + "y_label": "write_iops" + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_dashboards_1.json b/test/fixtures/monitor_dashboards_1.json new file mode 100644 index 000000000..b78bf3447 --- /dev/null +++ b/test/fixtures/monitor_dashboards_1.json @@ -0,0 +1,30 @@ +{ + "created": "2024-10-10T05:01:58", + "id": 1, + "label": "Resource Usage", + "service_type": "dbaas", + "type": "standard", + "updated": "2024-10-10T05:01:58", + "widgets": [ + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "CPU Usage", + "metric": "cpu_usage", + "size": 12, + "unit": "%", + "y_label": "cpu_usage" + }, + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "Available Memory", + "metric": "available_memory", + "size": 6, + "unit": "GB", + "y_label": "available_memory" + } + ] + } \ No newline at end of file diff --git a/test/fixtures/monitor_services.json b/test/fixtures/monitor_services.json new file mode 100644 index 000000000..7a568866c --- /dev/null +++ b/test/fixtures/monitor_services.json @@ -0,0 +1,11 @@ +{ + "data": [ + { + "label": "Databases", + "service_type": "dbaas" + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas.json b/test/fixtures/monitor_services_dbaas.json new file mode 100644 index 000000000..7a568866c --- /dev/null +++ b/test/fixtures/monitor_services_dbaas.json @@ -0,0 +1,11 @@ +{ + "data": [ + { + "label": "Databases", + "service_type": "dbaas" + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_dashboards.json b/test/fixtures/monitor_services_dbaas_dashboards.json new file mode 100644 index 000000000..5fbb7e9db --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_dashboards.json @@ -0,0 +1,37 @@ +{ + "data": [ + { + "created": "2024-10-10T05:01:58", + "id": 1, + "label": "Resource Usage", + "service_type": "dbaas", + "type": "standard", + "updated": "2024-10-10T05:01:58", + "widgets": [ + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "CPU Usage", + "metric": "cpu_usage", + "size": 12, + "unit": "%", + "y_label": "cpu_usage" + }, + { + "aggregate_function": "sum", + "chart_type": "area", + "color": "default", + "label": "Memory Usage", + "metric": "memory_usage", + "size": 6, + "unit": "%", + "y_label": "memory_usage" + } + ] + } + ], + "page": 1, + "pages": 1, + "results": 1 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_metric-definitions.json b/test/fixtures/monitor_services_dbaas_metric-definitions.json new file mode 100644 index 000000000..c493b23a3 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_metric-definitions.json @@ -0,0 +1,55 @@ +{ + "data": [ + { + "available_aggregate_functions": [ + "max", + "avg", + "min", + "sum" + ], + "dimensions": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "values": [ + "primary", + "secondary" + ] + } + ], + "is_alertable": true, + "label": "CPU Usage", + "metric": "cpu_usage", + "metric_type": "gauge", + "scrape_interval": "60s", + "unit": "percent" + }, + { + "available_aggregate_functions": [ + "max", + "avg", + "min", + "sum" + ], + "dimensions": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "values": [ + "primary", + "secondary" + ] + } + ], + "is_alertable": true, + "label": "Disk I/O Read", + "metric": "read_iops", + "metric_type": "gauge", + "scrape_interval": "60s", + "unit": "iops" + } + ], + "page": 1, + "pages": 1, + "results": 2 + } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_token.json b/test/fixtures/monitor_services_dbaas_token.json new file mode 100644 index 000000000..b1aa0d786 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_token.json @@ -0,0 +1,3 @@ +{ + "token": "abcdefhjigkfghh" +} \ No newline at end of file diff --git a/test/fixtures/monitor_services_linode_token.json b/test/fixtures/monitor_services_linode_token.json new file mode 100644 index 000000000..b1aa0d786 --- /dev/null +++ b/test/fixtures/monitor_services_linode_token.json @@ -0,0 +1,3 @@ +{ + "token": "abcdefhjigkfghh" +} \ No newline at end of file diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py new file mode 100644 index 000000000..5fb9626b3 --- /dev/null +++ b/test/integration/models/monitor/test_monitor.py @@ -0,0 +1,109 @@ +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, + wait_for_condition, +) + +import pytest + +from linode_api4 import LinodeClient +from linode_api4.objects import ( + MonitorDashboard, + MonitorMetricsDefinition, + MonitorService, + MonitorServiceToken, +) + + +# List all dashboards +def test_get_all_dashboards(test_linode_client): + client = test_linode_client + dashboards = client.monitor.dashboards() + assert isinstance(dashboards[0], MonitorDashboard) + + dashboard_get = dashboards[0] + get_service_type = dashboard_get.service_type + + # Fetch Dashboard by ID + dashboard_by_id = client.load(MonitorDashboard, 1) + assert isinstance(dashboard_by_id, MonitorDashboard) + assert dashboard_by_id.id == 1 + + # #Fetch Dashboard by service_type + dashboards_by_svc = client.monitor.dashboards(service_type=get_service_type) + assert isinstance(dashboards_by_svc[0], MonitorDashboard) + assert dashboards_by_svc[0].service_type == get_service_type + + +# List supported services +def test_get_supported_services(test_linode_client): + client = test_linode_client + supported_services = client.monitor.services() + assert isinstance(supported_services[0], MonitorService) + + get_supported_service = supported_services[0].service_type + + # Get details for a particular service + service_details = client.monitor.services( + service_type=get_supported_service + ) + assert isinstance(service_details[0], MonitorService) + assert service_details[0].service_type == get_supported_service + + # Get Metric definition details for that particular service + metric_definitions = client.monitor.metric_definitions( + service_type=get_supported_service + ) + assert isinstance(metric_definitions[0], MonitorMetricsDefinition) + + +# Test Helpers +def get_db_engine_id(client: LinodeClient, engine: str): + engines = client.database.engines() + engine_id = "" + for e in engines: + if e.engine == engine: + engine_id = e.id + + return str(engine_id) + + +@pytest.fixture(scope="session") +def test_create_and_test_db(test_linode_client): + client = test_linode_client + label = get_test_label() + "-sqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "mysql") + dbtype = "g6-standard-1" + + db = client.database.mysql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + send_request_when_resource_available(300, db.delete) + + +def test_my_db_functionality(test_linode_client, test_create_and_test_db): + client = test_linode_client + assert test_create_and_test_db.status == "active" + + entity_id = test_create_and_test_db.id + + # create token for the particular service + token = client.monitor.create_token( + service_type="dbaas", entity_ids=[entity_id] + ) + assert isinstance(token, MonitorServiceToken) + assert len(token.token) > 0, "Token should not be empty" + assert hasattr(token, "token"), "Response object has no 'token' attribute" diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py new file mode 100644 index 000000000..385eaf462 --- /dev/null +++ b/test/unit/objects/monitor_test.py @@ -0,0 +1,123 @@ +import datetime +from test.unit.base import ClientBaseCase + +from linode_api4.objects import MonitorDashboard + + +class MonitorTest(ClientBaseCase): + """ + Tests the methods of MonitorServiceSupported class + """ + + def test_supported_services(self): + """ + Test the services supported by monitor + """ + service = self.client.monitor.services() + self.assertEqual(len(service), 1) + self.assertEqual(service[0].label, "Databases") + self.assertEqual(service[0].service_type, "dbaas") + + def test_dashboard_by_ID(self): + """ + Test the dashboard by ID API + """ + dashboard = self.client.load(MonitorDashboard, 1) + self.assertEqual(dashboard.type, "standard") + self.assertEqual( + dashboard.created, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboard.id, 1) + self.assertEqual(dashboard.label, "Resource Usage") + self.assertEqual(dashboard.service_type, "dbaas") + self.assertEqual( + dashboard.updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboard.widgets[0].aggregate_function, "sum") + self.assertEqual(dashboard.widgets[0].chart_type, "area") + self.assertEqual(dashboard.widgets[0].color, "default") + self.assertEqual(dashboard.widgets[0].label, "CPU Usage") + self.assertEqual(dashboard.widgets[0].metric, "cpu_usage") + self.assertEqual(dashboard.widgets[0].size, 12) + self.assertEqual(dashboard.widgets[0].unit, "%") + self.assertEqual(dashboard.widgets[0].y_label, "cpu_usage") + + def test_dashboard_by_service_type(self): + dashboards = self.client.monitor.dashboards(service_type="dbaas") + self.assertEqual(dashboards[0].type, "standard") + self.assertEqual( + dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].id, 1) + self.assertEqual(dashboards[0].label, "Resource Usage") + self.assertEqual(dashboards[0].service_type, "dbaas") + self.assertEqual( + dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].widgets[0].chart_type, "area") + self.assertEqual(dashboards[0].widgets[0].color, "default") + self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") + self.assertEqual(dashboards[0].widgets[0].metric, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].size, 12) + self.assertEqual(dashboards[0].widgets[0].unit, "%") + self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + + def test_get_all_dashboards(self): + dashboards = self.client.monitor.dashboards() + self.assertEqual(dashboards[0].type, "standard") + self.assertEqual( + dashboards[0].created, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].id, 1) + self.assertEqual(dashboards[0].label, "Resource Usage") + self.assertEqual(dashboards[0].service_type, "dbaas") + self.assertEqual( + dashboards[0].updated, datetime.datetime(2024, 10, 10, 5, 1, 58) + ) + self.assertEqual(dashboards[0].widgets[0].aggregate_function, "sum") + self.assertEqual(dashboards[0].widgets[0].chart_type, "area") + self.assertEqual(dashboards[0].widgets[0].color, "default") + self.assertEqual(dashboards[0].widgets[0].label, "CPU Usage") + self.assertEqual(dashboards[0].widgets[0].metric, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].size, 12) + self.assertEqual(dashboards[0].widgets[0].unit, "%") + self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + + def test_specific_service_details(self): + data = self.client.monitor.services(service_type="dbaas") + self.assertEqual(data[0].label, "Databases") + self.assertEqual(data[0].service_type, "dbaas") + + def test_metric_definitions(self): + + metrics = self.client.monitor.metric_definitions(service_type="dbaas") + self.assertEqual( + metrics[0].available_aggregate_functions, + ["max", "avg", "min", "sum"], + ) + self.assertEqual(metrics[0].is_alertable, True) + self.assertEqual(metrics[0].label, "CPU Usage") + self.assertEqual(metrics[0].metric, "cpu_usage") + self.assertEqual(metrics[0].metric_type, "gauge") + self.assertEqual(metrics[0].scrape_interval, "60s") + self.assertEqual(metrics[0].unit, "percent") + self.assertEqual(metrics[0].dimensions[0].dimension_label, "node_type") + self.assertEqual(metrics[0].dimensions[0].label, "Node Type") + self.assertEqual( + metrics[0].dimensions[0].values, ["primary", "secondary"] + ) + + def test_create_token(self): + + with self.mock_post("/monitor/services/dbaas/token") as m: + self.client.monitor.create_token( + service_type="dbaas", entity_ids=[189690, 188020] + ) + self.assertEqual(m.return_dct["token"], "abcdefhjigkfghh") + + with self.mock_post("/monitor/services/linode/token") as m: + self.client.monitor.create_token( + service_type="linode", entity_ids=["compute-instance-1"] + ) + self.assertEqual(m.return_dct["token"], "abcdefhjigkfghh") From 7b7f6470c8d61f46f9561b1cdaa8fee7a090d607 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 6 Jun 2025 11:08:39 -0400 Subject: [PATCH 311/379] Reorganized DB unit tests (#561) --- test/unit/groups/database_test.py | 258 ++++++++++++++++++---------- test/unit/objects/database_test.py | 260 ----------------------------- 2 files changed, 166 insertions(+), 352 deletions(-) diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py index d1939aec7..9647fed82 100644 --- a/test/unit/groups/database_test.py +++ b/test/unit/groups/database_test.py @@ -73,65 +73,6 @@ def test_database_instance(self): self.assertTrue(isinstance(db_translated, MySQLDatabase)) self.assertEqual(db_translated.ssl_connection, True) - -class MySQLDatabaseTest(ClientBaseCase): - """ - Tests methods of the MySQLDatabase class - """ - - def test_get_instances(self): - """ - Test that database types are properly handled - """ - dbs = self.client.database.mysql_instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "mysql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-123-456-mysql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "8.0.26") - - def test_create(self): - """ - Test that MySQL databases can be created - """ - - with self.mock_post("/databases/mysql/instances") as m: - # We don't care about errors here; we just want to - # validate the request. - try: - self.client.database.mysql_create( - "cool", - "us-southeast", - "mysql/8.0.26", - "g6-standard-1", - cluster_size=3, - ) - except Exception as e: - logger.warning( - "An error occurred while validating the request: %s", e - ) - - self.assertEqual(m.method, "post") - self.assertEqual(m.call_url, "/databases/mysql/instances") - self.assertEqual(m.call_data["label"], "cool") - self.assertEqual(m.call_data["region"], "us-southeast") - self.assertEqual(m.call_data["engine"], "mysql/8.0.26") - self.assertEqual(m.call_data["type"], "g6-standard-1") - self.assertEqual(m.call_data["cluster_size"], 3) - def test_mysql_config_options(self): """ Test that MySQL configuration options can be retrieved @@ -1320,15 +1261,86 @@ def test_postgresql_config_options(self): self.assertFalse(config["work_mem"]["requires_restart"]) self.assertEqual("integer", config["work_mem"]["type"]) + def test_get_mysql_instances(self): + """ + Test that mysql instances can be retrieved properly + """ + dbs = self.client.database.mysql_instances() -class PostgreSQLDatabaseTest(ClientBaseCase): - """ - Tests methods of the PostgreSQLDatabase class - """ + self.assertEqual(len(dbs), 1) + self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") + self.assertEqual(dbs[0].cluster_size, 3) + self.assertEqual(dbs[0].encrypted, False) + self.assertEqual(dbs[0].engine, "mysql") + self.assertEqual( + dbs[0].hosts.primary, + "lin-123-456-mysql-mysql-primary.servers.linodedb.net", + ) + self.assertEqual( + dbs[0].hosts.secondary, + "lin-123-456-mysql-primary-private.servers.linodedb.net", + ) + self.assertEqual(dbs[0].id, 123) + self.assertEqual(dbs[0].region, "us-east") + self.assertEqual(dbs[0].updates.duration, 3) + self.assertEqual(dbs[0].version, "8.0.26") + self.assertEqual(dbs[0].engine_config.binlog_retention_period, 600) + self.assertEqual(dbs[0].engine_config.mysql.connect_timeout, 10) + self.assertEqual(dbs[0].engine_config.mysql.default_time_zone, "+03:00") + self.assertEqual(dbs[0].engine_config.mysql.group_concat_max_len, 1024) + self.assertEqual( + dbs[0].engine_config.mysql.information_schema_stats_expiry, 86400 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_change_buffer_max_size, 30 + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_flush_neighbors, 0) + self.assertEqual(dbs[0].engine_config.mysql.innodb_ft_min_token_size, 3) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_ft_server_stopword_table, + "db_name/table_name", + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_lock_wait_timeout, 50 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_log_buffer_size, 16777216 + ) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_online_alter_log_max_size, + 134217728, + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_read_io_threads, 10) + self.assertTrue(dbs[0].engine_config.mysql.innodb_rollback_on_timeout) + self.assertEqual( + dbs[0].engine_config.mysql.innodb_thread_concurrency, 10 + ) + self.assertEqual(dbs[0].engine_config.mysql.innodb_write_io_threads, 10) + self.assertEqual(dbs[0].engine_config.mysql.interactive_timeout, 3600) + self.assertEqual( + dbs[0].engine_config.mysql.internal_tmp_mem_storage_engine, + "TempTable", + ) + self.assertEqual( + dbs[0].engine_config.mysql.max_allowed_packet, 67108864 + ) + self.assertEqual( + dbs[0].engine_config.mysql.max_heap_table_size, 16777216 + ) + self.assertEqual(dbs[0].engine_config.mysql.net_buffer_length, 16384) + self.assertEqual(dbs[0].engine_config.mysql.net_read_timeout, 30) + self.assertEqual(dbs[0].engine_config.mysql.net_write_timeout, 30) + self.assertEqual(dbs[0].engine_config.mysql.sort_buffer_size, 262144) + self.assertEqual( + dbs[0].engine_config.mysql.sql_mode, "ANSI,TRADITIONAL" + ) + self.assertTrue(dbs[0].engine_config.mysql.sql_require_primary_key) + self.assertEqual(dbs[0].engine_config.mysql.tmp_table_size, 16777216) + self.assertEqual(dbs[0].engine_config.mysql.wait_timeout, 28800) - def test_get_instances(self): + def test_get_postgresql_instances(self): """ - Test that database types are properly handled + Test that postgresql instances can be retrieved properly """ dbs = self.client.database.postgresql_instances() @@ -1350,31 +1362,93 @@ def test_get_instances(self): self.assertEqual(dbs[0].updates.duration, 3) self.assertEqual(dbs[0].version, "13.2") - def test_create(self): - """ - Test that PostgreSQL databases can be created - """ + print(dbs[0].engine_config.pg.__dict__) - with self.mock_post("/databases/postgresql/instances") as m: - # We don't care about errors here; we just want to - # validate the request. - try: - self.client.database.postgresql_create( - "cool", - "us-southeast", - "postgresql/13.2", - "g6-standard-1", - cluster_size=3, - ) - except Exception as e: - logger.warning( - "An error occurred while validating the request: %s", e - ) - - self.assertEqual(m.method, "post") - self.assertEqual(m.call_url, "/databases/postgresql/instances") - self.assertEqual(m.call_data["label"], "cool") - self.assertEqual(m.call_data["region"], "us-southeast") - self.assertEqual(m.call_data["engine"], "postgresql/13.2") - self.assertEqual(m.call_data["type"], "g6-standard-1") - self.assertEqual(m.call_data["cluster_size"], 3) + self.assertTrue(dbs[0].engine_config.pg_stat_monitor_enable) + self.assertEqual( + dbs[0].engine_config.pglookout.max_failover_replication_time_lag, + 1000, + ) + self.assertEqual(dbs[0].engine_config.shared_buffers_percentage, 41.5) + self.assertEqual(dbs[0].engine_config.work_mem, 4) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_analyze_scale_factor, 0.5 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_analyze_threshold, 100 + ) + self.assertEqual(dbs[0].engine_config.pg.autovacuum_max_workers, 10) + self.assertEqual(dbs[0].engine_config.pg.autovacuum_naptime, 100) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_cost_delay, 50 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_cost_limit, 100 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_scale_factor, 0.5 + ) + self.assertEqual( + dbs[0].engine_config.pg.autovacuum_vacuum_threshold, 100 + ) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_delay, 200) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_flush_after, 512) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_maxpages, 100) + self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_multiplier, 2.0) + self.assertEqual(dbs[0].engine_config.pg.deadlock_timeout, 1000) + self.assertEqual( + dbs[0].engine_config.pg.default_toast_compression, "lz4" + ) + self.assertEqual( + dbs[0].engine_config.pg.idle_in_transaction_session_timeout, 100 + ) + self.assertTrue(dbs[0].engine_config.pg.jit) + self.assertEqual(dbs[0].engine_config.pg.max_files_per_process, 100) + self.assertEqual(dbs[0].engine_config.pg.max_locks_per_transaction, 100) + self.assertEqual( + dbs[0].engine_config.pg.max_logical_replication_workers, 32 + ) + self.assertEqual(dbs[0].engine_config.pg.max_parallel_workers, 64) + self.assertEqual( + dbs[0].engine_config.pg.max_parallel_workers_per_gather, 64 + ) + self.assertEqual( + dbs[0].engine_config.pg.max_pred_locks_per_transaction, 1000 + ) + self.assertEqual(dbs[0].engine_config.pg.max_replication_slots, 32) + self.assertEqual(dbs[0].engine_config.pg.max_slot_wal_keep_size, 100) + self.assertEqual(dbs[0].engine_config.pg.max_stack_depth, 3507152) + self.assertEqual( + dbs[0].engine_config.pg.max_standby_archive_delay, 1000 + ) + self.assertEqual( + dbs[0].engine_config.pg.max_standby_streaming_delay, 1000 + ) + self.assertEqual(dbs[0].engine_config.pg.max_wal_senders, 32) + self.assertEqual(dbs[0].engine_config.pg.max_worker_processes, 64) + self.assertEqual( + dbs[0].engine_config.pg.password_encryption, "scram-sha-256" + ) + self.assertEqual(dbs[0].engine_config.pg.pg_partman_bgw_interval, 3600) + self.assertEqual( + dbs[0].engine_config.pg.pg_partman_bgw_role, "myrolename" + ) + self.assertFalse( + dbs[0].engine_config.pg.pg_stat_monitor_pgsm_enable_query_plan + ) + self.assertEqual( + dbs[0].engine_config.pg.pg_stat_monitor_pgsm_max_buckets, 10 + ) + self.assertEqual( + dbs[0].engine_config.pg.pg_stat_statements_track, "top" + ) + self.assertEqual(dbs[0].engine_config.pg.temp_file_limit, 5000000) + self.assertEqual(dbs[0].engine_config.pg.timezone, "Europe/Helsinki") + self.assertEqual( + dbs[0].engine_config.pg.track_activity_query_size, 1024 + ) + self.assertEqual(dbs[0].engine_config.pg.track_commit_timestamp, "off") + self.assertEqual(dbs[0].engine_config.pg.track_functions, "all") + self.assertEqual(dbs[0].engine_config.pg.track_io_timing, "off") + self.assertEqual(dbs[0].engine_config.pg.wal_sender_timeout, 60000) + self.assertEqual(dbs[0].engine_config.pg.wal_writer_delay, 50) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 8605e43c5..c5abe3a58 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -13,156 +13,11 @@ logger = logging.getLogger(__name__) -class DatabaseTest(ClientBaseCase): - """ - Tests methods of the DatabaseGroup class - """ - - def test_get_types(self): - """ - Test that database types are properly handled - """ - types = self.client.database.types() - - self.assertEqual(len(types), 1) - self.assertEqual(types[0].type_class, "nanode") - self.assertEqual(types[0].id, "g6-nanode-1") - self.assertEqual(types[0].engines.mysql[0].price.monthly, 20) - - def test_get_engines(self): - """ - Test that database engines are properly handled - """ - engines = self.client.database.engines() - - self.assertEqual(len(engines), 2) - - self.assertEqual(engines[0].engine, "mysql") - self.assertEqual(engines[0].id, "mysql/8.0.26") - self.assertEqual(engines[0].version, "8.0.26") - - self.assertEqual(engines[1].engine, "postgresql") - self.assertEqual(engines[1].id, "postgresql/10.14") - self.assertEqual(engines[1].version, "10.14") - - def test_get_databases(self): - """ - Test that databases are properly handled - """ - dbs = self.client.database.instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "mysql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-123-456-mysql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "8.0.26") - - def test_database_instance(self): - """ - Ensures that the .instance attribute properly translates database types - """ - - dbs = self.client.database.instances() - db_translated = dbs[0].instance - - self.assertTrue(isinstance(db_translated, MySQLDatabase)) - self.assertEqual(db_translated.ssl_connection, True) - - class MySQLDatabaseTest(ClientBaseCase): """ Tests methods of the MySQLDatabase class """ - def test_get_instances(self): - """ - Test that database types are properly handled - """ - dbs = self.client.database.mysql_instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "mysql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-123-456-mysql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "8.0.26") - self.assertEqual(dbs[0].engine_config.binlog_retention_period, 600) - self.assertEqual(dbs[0].engine_config.mysql.connect_timeout, 10) - self.assertEqual(dbs[0].engine_config.mysql.default_time_zone, "+03:00") - self.assertEqual(dbs[0].engine_config.mysql.group_concat_max_len, 1024) - self.assertEqual( - dbs[0].engine_config.mysql.information_schema_stats_expiry, 86400 - ) - self.assertEqual( - dbs[0].engine_config.mysql.innodb_change_buffer_max_size, 30 - ) - self.assertEqual(dbs[0].engine_config.mysql.innodb_flush_neighbors, 0) - self.assertEqual(dbs[0].engine_config.mysql.innodb_ft_min_token_size, 3) - self.assertEqual( - dbs[0].engine_config.mysql.innodb_ft_server_stopword_table, - "db_name/table_name", - ) - self.assertEqual( - dbs[0].engine_config.mysql.innodb_lock_wait_timeout, 50 - ) - self.assertEqual( - dbs[0].engine_config.mysql.innodb_log_buffer_size, 16777216 - ) - self.assertEqual( - dbs[0].engine_config.mysql.innodb_online_alter_log_max_size, - 134217728, - ) - self.assertEqual(dbs[0].engine_config.mysql.innodb_read_io_threads, 10) - self.assertTrue(dbs[0].engine_config.mysql.innodb_rollback_on_timeout) - self.assertEqual( - dbs[0].engine_config.mysql.innodb_thread_concurrency, 10 - ) - self.assertEqual(dbs[0].engine_config.mysql.innodb_write_io_threads, 10) - self.assertEqual(dbs[0].engine_config.mysql.interactive_timeout, 3600) - self.assertEqual( - dbs[0].engine_config.mysql.internal_tmp_mem_storage_engine, - "TempTable", - ) - self.assertEqual( - dbs[0].engine_config.mysql.max_allowed_packet, 67108864 - ) - self.assertEqual( - dbs[0].engine_config.mysql.max_heap_table_size, 16777216 - ) - self.assertEqual(dbs[0].engine_config.mysql.net_buffer_length, 16384) - self.assertEqual(dbs[0].engine_config.mysql.net_read_timeout, 30) - self.assertEqual(dbs[0].engine_config.mysql.net_write_timeout, 30) - self.assertEqual(dbs[0].engine_config.mysql.sort_buffer_size, 262144) - self.assertEqual( - dbs[0].engine_config.mysql.sql_mode, "ANSI,TRADITIONAL" - ) - self.assertTrue(dbs[0].engine_config.mysql.sql_require_primary_key) - self.assertEqual(dbs[0].engine_config.mysql.tmp_table_size, 16777216) - self.assertEqual(dbs[0].engine_config.mysql.wait_timeout, 28800) - def test_create(self): """ Test that MySQL databases can be created @@ -378,121 +233,6 @@ class PostgreSQLDatabaseTest(ClientBaseCase): Tests methods of the PostgreSQLDatabase class """ - def test_get_instances(self): - """ - Test that database types are properly handled - """ - dbs = self.client.database.postgresql_instances() - - self.assertEqual(len(dbs), 1) - self.assertEqual(dbs[0].allow_list[1], "192.0.1.0/24") - self.assertEqual(dbs[0].cluster_size, 3) - self.assertEqual(dbs[0].encrypted, False) - self.assertEqual(dbs[0].engine, "postgresql") - self.assertEqual( - dbs[0].hosts.primary, - "lin-0000-000-pgsql-primary.servers.linodedb.net", - ) - self.assertEqual( - dbs[0].hosts.secondary, - "lin-0000-000-pgsql-primary-private.servers.linodedb.net", - ) - self.assertEqual(dbs[0].id, 123) - self.assertEqual(dbs[0].region, "us-east") - self.assertEqual(dbs[0].updates.duration, 3) - self.assertEqual(dbs[0].version, "13.2") - - print(dbs[0].engine_config.pg.__dict__) - - self.assertTrue(dbs[0].engine_config.pg_stat_monitor_enable) - self.assertEqual( - dbs[0].engine_config.pglookout.max_failover_replication_time_lag, - 1000, - ) - self.assertEqual(dbs[0].engine_config.shared_buffers_percentage, 41.5) - self.assertEqual(dbs[0].engine_config.work_mem, 4) - self.assertEqual( - dbs[0].engine_config.pg.autovacuum_analyze_scale_factor, 0.5 - ) - self.assertEqual( - dbs[0].engine_config.pg.autovacuum_analyze_threshold, 100 - ) - self.assertEqual(dbs[0].engine_config.pg.autovacuum_max_workers, 10) - self.assertEqual(dbs[0].engine_config.pg.autovacuum_naptime, 100) - self.assertEqual( - dbs[0].engine_config.pg.autovacuum_vacuum_cost_delay, 50 - ) - self.assertEqual( - dbs[0].engine_config.pg.autovacuum_vacuum_cost_limit, 100 - ) - self.assertEqual( - dbs[0].engine_config.pg.autovacuum_vacuum_scale_factor, 0.5 - ) - self.assertEqual( - dbs[0].engine_config.pg.autovacuum_vacuum_threshold, 100 - ) - self.assertEqual(dbs[0].engine_config.pg.bgwriter_delay, 200) - self.assertEqual(dbs[0].engine_config.pg.bgwriter_flush_after, 512) - self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_maxpages, 100) - self.assertEqual(dbs[0].engine_config.pg.bgwriter_lru_multiplier, 2.0) - self.assertEqual(dbs[0].engine_config.pg.deadlock_timeout, 1000) - self.assertEqual( - dbs[0].engine_config.pg.default_toast_compression, "lz4" - ) - self.assertEqual( - dbs[0].engine_config.pg.idle_in_transaction_session_timeout, 100 - ) - self.assertTrue(dbs[0].engine_config.pg.jit) - self.assertEqual(dbs[0].engine_config.pg.max_files_per_process, 100) - self.assertEqual(dbs[0].engine_config.pg.max_locks_per_transaction, 100) - self.assertEqual( - dbs[0].engine_config.pg.max_logical_replication_workers, 32 - ) - self.assertEqual(dbs[0].engine_config.pg.max_parallel_workers, 64) - self.assertEqual( - dbs[0].engine_config.pg.max_parallel_workers_per_gather, 64 - ) - self.assertEqual( - dbs[0].engine_config.pg.max_pred_locks_per_transaction, 1000 - ) - self.assertEqual(dbs[0].engine_config.pg.max_replication_slots, 32) - self.assertEqual(dbs[0].engine_config.pg.max_slot_wal_keep_size, 100) - self.assertEqual(dbs[0].engine_config.pg.max_stack_depth, 3507152) - self.assertEqual( - dbs[0].engine_config.pg.max_standby_archive_delay, 1000 - ) - self.assertEqual( - dbs[0].engine_config.pg.max_standby_streaming_delay, 1000 - ) - self.assertEqual(dbs[0].engine_config.pg.max_wal_senders, 32) - self.assertEqual(dbs[0].engine_config.pg.max_worker_processes, 64) - self.assertEqual( - dbs[0].engine_config.pg.password_encryption, "scram-sha-256" - ) - self.assertEqual(dbs[0].engine_config.pg.pg_partman_bgw_interval, 3600) - self.assertEqual( - dbs[0].engine_config.pg.pg_partman_bgw_role, "myrolename" - ) - self.assertFalse( - dbs[0].engine_config.pg.pg_stat_monitor_pgsm_enable_query_plan - ) - self.assertEqual( - dbs[0].engine_config.pg.pg_stat_monitor_pgsm_max_buckets, 10 - ) - self.assertEqual( - dbs[0].engine_config.pg.pg_stat_statements_track, "top" - ) - self.assertEqual(dbs[0].engine_config.pg.temp_file_limit, 5000000) - self.assertEqual(dbs[0].engine_config.pg.timezone, "Europe/Helsinki") - self.assertEqual( - dbs[0].engine_config.pg.track_activity_query_size, 1024 - ) - self.assertEqual(dbs[0].engine_config.pg.track_commit_timestamp, "off") - self.assertEqual(dbs[0].engine_config.pg.track_functions, "all") - self.assertEqual(dbs[0].engine_config.pg.track_io_timing, "off") - self.assertEqual(dbs[0].engine_config.pg.wal_sender_timeout, 60000) - self.assertEqual(dbs[0].engine_config.pg.wal_writer_delay, 50) - def test_create(self): """ Test that PostgreSQL databases can be created From 389905e117b7177c377464bac3d68a18a0e99c42 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 30 Jun 2025 13:51:27 -0400 Subject: [PATCH 312/379] Fix Tests for v5.33 release (#567) * Add 503 status code to retry in `send_request_when_resource_available` * Remove error message assertions in VPC tests --- test/integration/helpers.py | 2 +- test/integration/models/vpc/test_vpc.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 0ee9810a8..9777d5950 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -43,7 +43,7 @@ def send_request_when_resource_available( timeout: int, func: Callable, *args, **kwargs ) -> object: start_time = time.time() - retry_statuses = {400, 500} + retry_statuses = {400, 500, 503} while True: try: diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 5dd14b502..0e9d27aff 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -56,7 +56,6 @@ def test_fails_create_vpc_invalid_data(test_linode_client): description="test description", ) assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) def test_get_all_vpcs(test_linode_client, create_multiple_vpcs): @@ -78,7 +77,6 @@ def test_fails_update_vpc_invalid_data(create_vpc): vpc.save() assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) def test_fails_create_subnet_invalid_data(create_vpc): @@ -88,7 +86,6 @@ def test_fails_create_subnet_invalid_data(create_vpc): create_vpc.subnet_create("test-subnet", ipv4=invalid_ipv4) assert excinfo.value.status == 400 - assert "ipv4 must be an IPv4 network" in str(excinfo.value.json) def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): @@ -100,4 +97,3 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): subnet.save() assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) From b6ccfd3362a506ccc2cc5b62e9781b60213e02be Mon Sep 17 00:00:00 2001 From: pmajali Date: Wed, 9 Jul 2025 23:35:08 +0530 Subject: [PATCH 313/379] updating MonitorService class (#568) * updating services endpoint * resolving lint errors --- linode_api4/groups/monitor.py | 18 ++++++-------- linode_api4/objects/monitor.py | 11 +++++---- test/fixtures/monitor_services_dbaas.json | 24 +++++++++++-------- .../models/monitor/test_monitor.py | 8 +++---- test/unit/objects/monitor_test.py | 8 +++---- 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 908b4e819..14b5617c4 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -62,32 +62,28 @@ def dashboards( ) def services( - self, *filters, service_type: Optional[str] = None - ) -> list[MonitorService]: + self, + *filters, + ) -> PaginatedList: """ Lists services supported by ACLP. supported_services = client.monitor.services() - service_details = client.monitor.services(service_type="dbaas") + service_details = client.monitor.load(MonitorService, "dbaas") .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services-for-service-type - :param service_type: The service type to get details for. - :type service_type: Optional[str] :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` for more details on filtering. - :returns: Lists monitor services by a given service_type + :returns: Lists monitor services :rtype: PaginatedList of the Services """ - endpoint = ( - f"/monitor/services/{service_type}" - if service_type - else "/monitor/services" - ) + endpoint = "/monitor/services" + return self.client._get_and_filter( MonitorService, *filters, diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index f518e641d..ae3936ee7 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -157,16 +157,19 @@ class MonitorDashboard(Base): } -@dataclass -class MonitorService(JSONObject): +class MonitorService(Base): """ Represents a single service type. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-monitor-services """ - service_type: ServiceType = "" - label: str = "" + api_endpoint = "/monitor/services/{service_type}" + id_attribute = "service_type" + properties = { + "service_type": Property(ServiceType), + "label": Property(), + } @dataclass diff --git a/test/fixtures/monitor_services_dbaas.json b/test/fixtures/monitor_services_dbaas.json index 7a568866c..211833847 100644 --- a/test/fixtures/monitor_services_dbaas.json +++ b/test/fixtures/monitor_services_dbaas.json @@ -1,11 +1,15 @@ { - "data": [ - { - "label": "Databases", - "service_type": "dbaas" - } - ], - "page": 1, - "pages": 1, - "results": 1 - } \ No newline at end of file + "service_type": "dbaas", + "label": "Databases", + "alert": { + "polling_interval_seconds": [ + 300 + ], + "evaluation_period_seconds": [ + 300 + ], + "scope": [ + "entity" + ] + } +} \ No newline at end of file diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index 5fb9626b3..7c9249f42 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -44,11 +44,9 @@ def test_get_supported_services(test_linode_client): get_supported_service = supported_services[0].service_type # Get details for a particular service - service_details = client.monitor.services( - service_type=get_supported_service - ) - assert isinstance(service_details[0], MonitorService) - assert service_details[0].service_type == get_supported_service + service_details = client.load(MonitorService, get_supported_service) + assert isinstance(service_details, MonitorService) + assert service_details.service_type == get_supported_service # Get Metric definition details for that particular service metric_definitions = client.monitor.metric_definitions( diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 385eaf462..a010514c2 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 MonitorDashboard +from linode_api4.objects import MonitorDashboard, MonitorService class MonitorTest(ClientBaseCase): @@ -85,9 +85,9 @@ def test_get_all_dashboards(self): self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") def test_specific_service_details(self): - data = self.client.monitor.services(service_type="dbaas") - self.assertEqual(data[0].label, "Databases") - self.assertEqual(data[0].service_type, "dbaas") + data = self.client.load(MonitorService, "dbaas") + self.assertEqual(data.label, "Databases") + self.assertEqual(data.service_type, "dbaas") def test_metric_definitions(self): From 818feb8d6ca440cf26d7c8a87d1e2bddcbaccf54 Mon Sep 17 00:00:00 2001 From: Youjung Kim <126618609+ykim-akamai@users.noreply.github.com> Date: Tue, 15 Jul 2025 07:03:55 -0700 Subject: [PATCH 314/379] Add basic model filter integration test coverage (#563) --- Makefile | 2 +- test/integration/filters/fixtures.py | 39 +++++++++ .../integration/filters/model_filters_test.py | 84 +++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 test/integration/filters/fixtures.py create mode 100644 test/integration/filters/model_filters_test.py diff --git a/Makefile b/Makefile index 4bfb1c348..ce7ef77d0 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,7 @@ lint: build # TEST_CASE: Optional, specify a test case (e.g. 'test_image_replication') # TEST_ARGS: Optional, additional arguments for pytest (e.g. '-v' for verbose mode) -TEST_COMMAND = $(if $(TEST_SUITE),$(if $(filter $(TEST_SUITE),linode_client login_client),$(TEST_SUITE),models/$(TEST_SUITE))) +TEST_COMMAND = $(if $(TEST_SUITE),$(if $(filter $(TEST_SUITE),linode_client login_client filters),$(TEST_SUITE),models/$(TEST_SUITE))) .PHONY: test-int test-int: diff --git a/test/integration/filters/fixtures.py b/test/integration/filters/fixtures.py new file mode 100644 index 000000000..344303eee --- /dev/null +++ b/test/integration/filters/fixtures.py @@ -0,0 +1,39 @@ +from test.integration.conftest import get_region +from test.integration.helpers import get_test_label + +import pytest + + +@pytest.fixture(scope="package") +def domain_instance(test_linode_client): + client = test_linode_client + + domain_addr = get_test_label(5) + "-example.com" + soa_email = "dx-test-email@linode.com" + + domain = client.domain_create(domain=domain_addr, soa_email=soa_email) + + yield domain + + domain.delete() + + +@pytest.fixture(scope="package") +def lke_cluster_instance(test_linode_client): + node_type = test_linode_client.linode.types()[1] # g6-standard-1 + version = test_linode_client.lke.versions()[0] + + region = get_region( + test_linode_client, {"Kubernetes", "LA Disk Encryption"} + ) + + node_pools = test_linode_client.lke.node_pool(node_type, 3) + label = get_test_label() + "_cluster" + + cluster = test_linode_client.lke.cluster_create( + region, label, node_pools, version + ) + + yield cluster + + cluster.delete() diff --git a/test/integration/filters/model_filters_test.py b/test/integration/filters/model_filters_test.py new file mode 100644 index 000000000..22bb8299e --- /dev/null +++ b/test/integration/filters/model_filters_test.py @@ -0,0 +1,84 @@ +from test.integration.filters.fixtures import ( # noqa: F401 + domain_instance, + lke_cluster_instance, +) + +from linode_api4.objects import ( + DatabaseEngine, + DatabaseType, + Domain, + Firewall, + Image, + LKECluster, + Type, +) + + +def test_database_type_model_filter(test_linode_client): + client = test_linode_client + + db_disk = client.database.types()[0].disk + + filtered_db_type = client.database.types(DatabaseType.disk == db_disk) + + assert db_disk == filtered_db_type[0].disk + + +def test_database_engine_model_filter(test_linode_client): + client = test_linode_client + + engine = "mysql" + + filtered_db_engine = client.database.engines( + DatabaseEngine.engine == engine + ) + + assert len(client.database.engines()) > len(filtered_db_engine) + + +def test_domain_model_filter(test_linode_client, domain_instance): + client = test_linode_client + + filtered_domain = client.domains(Domain.domain == domain_instance.domain) + + assert domain_instance.id == filtered_domain[0].id + + +def test_image_model_filter(test_linode_client): + client = test_linode_client + + filtered_images = client.images(Image.label.contains("Debian")) + + assert len(client.images()) > len(filtered_images) + + +def test_linode_type_model_filter(test_linode_client): + client = test_linode_client + + filtered_types = client.linode.types(Type.label.contains("Linode")) + + assert len(filtered_types) > 0 + assert "Linode" in filtered_types[0].label + + +def test_lke_cluster_model_filter(test_linode_client, lke_cluster_instance): + client = test_linode_client + + filtered_cluster = client.lke.clusters( + LKECluster.label.contains(lke_cluster_instance.label) + ) + + assert filtered_cluster[0].id == lke_cluster_instance.id + + +def test_networking_firewall_model_filter( + test_linode_client, e2e_test_firewall +): + client = test_linode_client + + filtered_firewall = client.networking.firewalls( + Firewall.label.contains(e2e_test_firewall.label) + ) + + assert len(filtered_firewall) > 0 + assert e2e_test_firewall.label in filtered_firewall[0].label From e35ffe81a546fb18b29068843648522882f41ac3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:54:10 -0400 Subject: [PATCH 315/379] build(deps): bump slackapi/slack-github-action from 2.1.0 to 2.1.1 (#570) Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 2.1.0 to 2.1.1. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Commits](https://github.com/slackapi/slack-github-action/compare/v2.1.0...v2.1.1) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-version: 2.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test.yml | 4 ++-- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/release-notify-slack.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index d08999645..1c4ec8540 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -232,7 +232,7 @@ jobs: steps: - name: Notify Slack id: main_message - uses: slackapi/slack-github-action@v2.1.0 + uses: slackapi/slack-github-action@v2.1.1 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -264,7 +264,7 @@ jobs: - name: Test summary thread if: success() - uses: slackapi/slack-github-action@v2.1.0 + uses: slackapi/slack-github-action@v2.1.1 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 3f6083a98..dc41e1600 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -45,7 +45,7 @@ jobs: - name: Notify Slack if: always() && github.repository == 'linode/linode_api4-python' - uses: slackapi/slack-github-action@v2.1.0 + uses: slackapi/slack-github-action@v2.1.1 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml index f2739e988..4b01f094b 100644 --- a/.github/workflows/release-notify-slack.yml +++ b/.github/workflows/release-notify-slack.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Notify Slack - Main Message id: main_message - uses: slackapi/slack-github-action@v2.1.0 + uses: slackapi/slack-github-action@v2.1.1 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} From de7cde17026e8b333eafb15e00215825d8f7e74e Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 22 Jul 2025 11:20:54 -0400 Subject: [PATCH 316/379] Fix timeout integration test cases (#575) --- test/integration/models/account/test_account.py | 4 ++-- test/integration/models/linode/test_linode.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index decad434f..72cd97cda 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -1,7 +1,7 @@ import time from datetime import datetime from test.integration.conftest import get_region -from test.integration.helpers import get_test_label +from test.integration.helpers import get_test_label, retry_sending_request import pytest @@ -37,7 +37,7 @@ def test_get_account(test_linode_client): def test_get_login(test_linode_client): client = test_linode_client - login = client.load(Login(client, "", {}), "") + login = retry_sending_request(3, client.load, Login(client, "", {}), "") updated_time = int(time.mktime(getattr(login, "_last_updated").timetuple())) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index ade4ca5ed..97965f2b9 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -252,7 +252,7 @@ def test_linode_rebuild(test_linode_client): disk_encryption=InstanceDiskEncryptionType.disabled, ) - wait_for_condition(10, 100, get_status, linode, "rebuilding") + wait_for_condition(10, 300, get_status, linode, "rebuilding") assert linode.status == "rebuilding" assert linode.image.id == "linode/debian12" From 9987e83e32dd97f82eaaa725a99b9edc37e313b4 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:54:08 -0400 Subject: [PATCH 317/379] Raise the exception from the API error after retry in tests (#576) --- test/integration/helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/integration/helpers.py b/test/integration/helpers.py index 9777d5950..969ca70a9 100644 --- a/test/integration/helpers.py +++ b/test/integration/helpers.py @@ -31,11 +31,11 @@ def retry_sending_request( for attempt in range(1, retries + 1): try: return condition(*args, **kwargs) - except ApiError: + except ApiError as e: if attempt == retries: - raise ApiError( + raise Exception( "Api Error: Failed after all retry attempts" - ) from None + ) from e time.sleep(backoff) From 74e272ad5ce6a8b4c64bc2d48955eed537a5db47 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:00:37 -0400 Subject: [PATCH 318/379] Support Monitor Client and Fetch Entity Metrics (#569) * init * lint * define all * clean up * lint * fix import * fix import * add int test --- linode_api4/__init__.py | 2 +- linode_api4/groups/__init__.py | 1 + linode_api4/groups/group.py | 4 +- linode_api4/groups/monitor.py | 4 +- linode_api4/groups/monitor_api.py | 59 +++ linode_api4/linode_client.py | 383 ++++++++++++------ linode_api4/objects/__init__.py | 1 + linode_api4/objects/monitor.py | 4 +- linode_api4/objects/monitor_api.py | 44 ++ .../monitor_services_dbaas_metrics.json | 47 +++ test/integration/conftest.py | 75 +++- .../models/monitor_api/test_monitor_api.py | 12 + test/unit/base.py | 28 +- test/unit/groups/monitor_api_test.py | 52 +++ 14 files changed, 575 insertions(+), 141 deletions(-) create mode 100644 linode_api4/groups/monitor_api.py create mode 100644 linode_api4/objects/monitor_api.py create mode 100644 test/fixtures/monitor_services_dbaas_metrics.json create mode 100644 test/integration/models/monitor_api/test_monitor_api.py create mode 100644 test/unit/groups/monitor_api_test.py diff --git a/linode_api4/__init__.py b/linode_api4/__init__.py index b347b607d..69fa1111c 100644 --- a/linode_api4/__init__.py +++ b/linode_api4/__init__.py @@ -1,7 +1,7 @@ # isort: skip_file from linode_api4.objects import * from linode_api4.errors import ApiError, UnexpectedResponseError -from linode_api4.linode_client import LinodeClient +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.login_client import LinodeLoginClient, OAuthScopes from linode_api4.paginated_list import PaginatedList from linode_api4.polling import EventPoller diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 3842042ad..4096cd21c 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -11,6 +11,7 @@ from .lke_tier import * from .longview import * from .monitor import * +from .monitor_api import * from .networking import * from .nodebalancer import * from .object_storage import * diff --git a/linode_api4/groups/group.py b/linode_api4/groups/group.py index c591b7fda..b7c0e1eeb 100644 --- a/linode_api4/groups/group.py +++ b/linode_api4/groups/group.py @@ -3,9 +3,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from linode_api4 import LinodeClient + from linode_api4.linode_client import BaseClient class Group: - def __init__(self, client: LinodeClient): + def __init__(self, client: BaseClient): self.client = client diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 14b5617c4..2dbfd2285 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -3,9 +3,7 @@ ] from typing import Any, Optional -from linode_api4 import ( - PaginatedList, -) +from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( diff --git a/linode_api4/groups/monitor_api.py b/linode_api4/groups/monitor_api.py new file mode 100644 index 000000000..48e2b2c30 --- /dev/null +++ b/linode_api4/groups/monitor_api.py @@ -0,0 +1,59 @@ +__all__ = [ + "MetricsGroup", +] + +from typing import Any, Dict, List, Optional, Union + +from linode_api4 import drop_null_keys +from linode_api4.groups import Group +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.objects.monitor_api import EntityMetricOptions, EntityMetrics + + +class MetricsGroup(Group): + """ + Encapsulates Monitor-related methods of the :any:`MonitorClient`. + + This group contains all features related to metrics in the API monitor-api. + """ + + def fetch_metrics( + self, + service_type: str, + entity_ids: list, + metrics: List[Union[EntityMetricOptions, Dict[str, Any]]], + **kwargs, + ) -> Optional[EntityMetrics]: + """ + Returns metrics information for the individual entities within a specific service type. + + API documentation: https://techdocs.akamai.com/linode-api/reference/post-read-metric + + :param service_type: The service being monitored. + Currently, only the Managed Databases (dbaas) service type is supported. + :type service_type: str + + :param entity_ids: The id for each individual entity from a service_type. + :type entity_ids: list + + :param metrics: A list of metric objects, each specifying a metric name and its corresponding aggregation function. + :type metrics: list of EntityMetricOptions or Dict[str, Any] + + :param kwargs: Any other arguments accepted by the api. Please refer to the API documentation for full info. + + :returns: Service metrics requested. + :rtype: EntityMetrics or None + """ + params = { + "entity_ids": entity_ids, + "metrics": metrics, + } + + params.update(kwargs) + + result = self.client.post( + f"/monitor/services/{service_type}/metrics", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + return EntityMetrics.from_json(result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index e71f1563e..d1e35761e 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -19,6 +19,7 @@ LinodeGroup, LKEGroup, LongviewGroup, + MetricsGroup, MonitorGroup, NetworkingGroup, NodeBalancerGroup, @@ -51,11 +52,48 @@ def get_backoff_time(self): return self.backoff_factor -class LinodeClient: +class BaseClient: + """ + The base class for a client. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + def __init__( self, token, - base_url="https://api.linode.com/v4", + base_url, user_agent=None, page_size=None, retry=True, @@ -64,42 +102,6 @@ def __init__( retry_statuses=None, ca_path=None, ): - """ - The main interface to the Linode API. - - :param token: The authentication token to use for communication with the - API. Can be either a Personal Access Token or an OAuth Token. - :type token: str - :param base_url: The base URL for API requests. Generally, you shouldn't - change this. - :type base_url: str - :param user_agent: What to append to the User Agent of all requests made - by this client. Setting this allows Linode's internal - monitoring applications to track the usage of your - application. Setting this is not necessary, but some - applications may desire this behavior. - :type user_agent: str - :param page_size: The default size to request pages at. If not given, - the API's default page size is used. Valid values - can be found in the API docs, but at time of writing - are between 25 and 500. - :type page_size: int - :param retry: Whether API requests should automatically be retries on known - intermittent responses. - :type retry: bool - :param retry_rate_limit_interval: The amount of time to wait between HTTP request - retries. - :type retry_rate_limit_interval: Union[float, int] - :param retry_max: The number of request retries that should be attempted before - raising an API error. - :type retry_max: int - :type retry_statuses: List of int - :param retry_statuses: Additional HTTP response statuses to retry on. - By default, the client will retry on 408, 429, and 502 - responses. - :param ca_path: The path to a CA file to use for API requests in this client. - :type ca_path: str - """ self.base_url = base_url self._add_user_agent = user_agent self.token = token @@ -138,72 +140,6 @@ def __init__( self.session.mount("http://", retry_adapter) self.session.mount("https://", retry_adapter) - #: Access methods related to Linodes - see :any:`LinodeGroup` for - #: more information - self.linode = LinodeGroup(self) - - #: Access methods related to your user - see :any:`ProfileGroup` for - #: more information - self.profile = ProfileGroup(self) - - #: Access methods related to your account - see :any:`AccountGroup` for - #: more information - self.account = AccountGroup(self) - - #: Access methods related to networking on your account - see - #: :any:`NetworkingGroup` for more information - self.networking = NetworkingGroup(self) - - #: Access methods related to support - see :any:`SupportGroup` for more - #: information - self.support = SupportGroup(self) - - #: Access information related to the Longview service - see - #: :any:`LongviewGroup` for more information - self.longview = LongviewGroup(self) - - #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` - #: for more information - self.object_storage = ObjectStorageGroup(self) - - #: Access methods related to LKE - see :any:`LKEGroup` for more information. - self.lke = LKEGroup(self) - - #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. - self.database = DatabaseGroup(self) - - #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. - self.nodebalancers = NodeBalancerGroup(self) - - #: Access methods related to Domains - see :any:`DomainGroup` for more information. - self.domains = DomainGroup(self) - - #: Access methods related to Tags - See :any:`TagGroup` for more information. - self.tags = TagGroup(self) - - #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. - self.volumes = VolumeGroup(self) - - #: Access methods related to Regions - See :any:`RegionGroup` for more information. - self.regions = RegionGroup(self) - - #: Access methods related to Images - See :any:`ImageGroup` for more information. - self.images = ImageGroup(self) - - #: Access methods related to VPCs - See :any:`VPCGroup` for more information. - self.vpcs = VPCGroup(self) - - #: Access methods related to Event polling - See :any:`PollingGroup` for more information. - self.polling = PollingGroup(self) - - #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. - self.beta = BetaProgramGroup(self) - - #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. - self.placement = PlacementAPIGroup(self) - - self.monitor = MonitorGroup(self) - @property def _user_agent(self): return "{}python-linode_api4/{} {}".format( @@ -367,6 +303,164 @@ def __setattr__(self, key, value): super().__setattr__(key, value) + # helper functions + def _get_and_filter( + self, + obj_type, + *filters, + endpoint=None, + parent_id=None, + ): + parsed_filters = None + if filters: + if len(filters) > 1: + parsed_filters = and_( + *filters + ).dct # pylint: disable=no-value-for-parameter + else: + parsed_filters = filters[0].dct + + # Use sepcified endpoint + if endpoint: + return self._get_objects( + endpoint, obj_type, parent_id=parent_id, filters=parsed_filters + ) + else: + return self._get_objects( + obj_type.api_list(), + obj_type, + parent_id=parent_id, + filters=parsed_filters, + ) + + +class LinodeClient(BaseClient): + def __init__( + self, + token, + base_url="https://api.linode.com/v4", + user_agent=None, + page_size=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, + ca_path=None, + ): + """ + The main interface to the Linode API. + + :param token: The authentication token to use for communication with the + API. Can be either a Personal Access Token or an OAuth Token. + :type token: str + :param base_url: The base URL for API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs, but at time of writing + are between 25 and 500. + :type page_size: int + :param retry: Whether API requests should automatically be retries on known + intermittent responses. + :type retry: bool + :param retry_rate_limit_interval: The amount of time to wait between HTTP request + retries. + :type retry_rate_limit_interval: Union[float, int] + :param retry_max: The number of request retries that should be attempted before + raising an API error. + :type retry_max: int + :type retry_statuses: List of int + :param retry_statuses: Additional HTTP response statuses to retry on. + By default, the client will retry on 408, 429, and 502 + responses. + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + #: Access methods related to Linodes - see :any:`LinodeGroup` for + #: more information + self.linode = LinodeGroup(self) + + #: Access methods related to your user - see :any:`ProfileGroup` for + #: more information + self.profile = ProfileGroup(self) + + #: Access methods related to your account - see :any:`AccountGroup` for + #: more information + self.account = AccountGroup(self) + + #: Access methods related to networking on your account - see + #: :any:`NetworkingGroup` for more information + self.networking = NetworkingGroup(self) + + #: Access methods related to support - see :any:`SupportGroup` for more + #: information + self.support = SupportGroup(self) + + #: Access information related to the Longview service - see + #: :any:`LongviewGroup` for more information + self.longview = LongviewGroup(self) + + #: Access methods related to Object Storage - see :any:`ObjectStorageGroup` + #: for more information + self.object_storage = ObjectStorageGroup(self) + + #: Access methods related to LKE - see :any:`LKEGroup` for more information. + self.lke = LKEGroup(self) + + #: Access methods related to Managed Databases - see :any:`DatabaseGroup` for more information. + self.database = DatabaseGroup(self) + + #: Access methods related to NodeBalancers - see :any:`NodeBalancerGroup` for more information. + self.nodebalancers = NodeBalancerGroup(self) + + #: Access methods related to Domains - see :any:`DomainGroup` for more information. + self.domains = DomainGroup(self) + + #: Access methods related to Tags - See :any:`TagGroup` for more information. + self.tags = TagGroup(self) + + #: Access methods related to Volumes - See :any:`VolumeGroup` for more information. + self.volumes = VolumeGroup(self) + + #: Access methods related to Regions - See :any:`RegionGroup` for more information. + self.regions = RegionGroup(self) + + #: Access methods related to Images - See :any:`ImageGroup` for more information. + self.images = ImageGroup(self) + + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. + self.vpcs = VPCGroup(self) + + #: Access methods related to Event polling - See :any:`PollingGroup` for more information. + self.polling = PollingGroup(self) + + #: Access methods related to Beta Program - See :any:`BetaProgramGroup` for more information. + self.beta = BetaProgramGroup(self) + + #: Access methods related to VM placement - See :any:`PlacementAPIGroup` for more information. + self.placement = PlacementAPIGroup(self) + + self.monitor = MonitorGroup(self) + + super().__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. @@ -457,32 +551,59 @@ def volume_create(self, label, region=None, linode=None, size=20, **kwargs): label, region=region, linode=linode, size=size, **kwargs ) - # helper functions - def _get_and_filter( + +class MonitorClient(BaseClient): + """ + The main interface to the Monitor API. + + :param token: The authentication Personal Access Token token to use for + communication with the API. You may want to generate one using + Linode Client. For example: + linode_client.monitor.create_token( + service_type="dbaas", entity_ids=[entity_id] + ) + :type token: str + :param base_url: The base URL for monitor API requests. Generally, you shouldn't + change this. + :type base_url: str + :param user_agent: What to append to the User Agent of all requests made + by this client. Setting this allows Linode's internal + monitoring applications to track the usage of your + application. Setting this is not necessary, but some + applications may desire this behavior. + :type user_agent: str + :param page_size: The default size to request pages at. If not given, + the API's default page size is used. Valid values + can be found in the API docs. + :type page_size: int + :param ca_path: The path to a CA file to use for API requests in this client. + :type ca_path: str + """ + + def __init__( self, - obj_type, - *filters, - endpoint=None, - parent_id=None, + token, + base_url="https://monitor-api.linode.com/v2beta", + user_agent=None, + page_size=None, + ca_path=None, + retry=True, + retry_rate_limit_interval=1.0, + retry_max=5, + retry_statuses=None, ): - parsed_filters = None - if filters: - if len(filters) > 1: - parsed_filters = and_( - *filters - ).dct # pylint: disable=no-value-for-parameter - else: - parsed_filters = filters[0].dct - - # Use sepcified endpoint - if endpoint: - return self._get_objects( - endpoint, obj_type, parent_id=parent_id, filters=parsed_filters - ) - else: - return self._get_objects( - obj_type.api_list(), - obj_type, - parent_id=parent_id, - filters=parsed_filters, - ) + #: Access methods related to your monitor metrics - see :any:`MetricsGroup` for + #: more information + self.metrics = MetricsGroup(self) + + super().__init__( + token=token, + base_url=base_url, + user_agent=user_agent, + page_size=page_size, + retry=retry, + retry_rate_limit_interval=retry_rate_limit_interval, + retry_max=retry_max, + retry_statuses=retry_statuses, + ca_path=ca_path, + ) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 7f1542d2a..c847024d8 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -22,3 +22,4 @@ from .beta import * from .placement import * from .monitor import * +from .monitor_api import * diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ae3936ee7..ed6ce79a5 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -3,11 +3,13 @@ "MonitorMetricsDefinition", "MonitorService", "MonitorServiceToken", + "AggregateFunction", ] from dataclasses import dataclass, field from typing import List, Optional -from linode_api4.objects import Base, JSONObject, Property, StrEnum +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum class AggregateFunction(StrEnum): diff --git a/linode_api4/objects/monitor_api.py b/linode_api4/objects/monitor_api.py new file mode 100644 index 000000000..c3496668c --- /dev/null +++ b/linode_api4/objects/monitor_api.py @@ -0,0 +1,44 @@ +__all__ = [ + "EntityMetrics", + "EntityMetricsData", + "EntityMetricsDataResult", + "EntityMetricsStats", + "EntityMetricOptions", +] +from dataclasses import dataclass, field +from typing import List, Optional + +from linode_api4.objects.monitor import AggregateFunction +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class EntityMetricsStats(JSONObject): + executionTimeMsec: int = 0 + seriesFetched: str = "" + + +@dataclass +class EntityMetricsDataResult(JSONObject): + metric: dict = field(default_factory=dict) + values: list = field(default_factory=list) + + +@dataclass +class EntityMetricsData(JSONObject): + result: Optional[List[EntityMetricsDataResult]] = None + resultType: str = "" + + +@dataclass +class EntityMetrics(JSONObject): + data: Optional[EntityMetricsData] = None + isPartial: bool = False + stats: Optional[EntityMetricsStats] = None + status: str = "" + + +@dataclass +class EntityMetricOptions(JSONObject): + name: str = "" + aggregate_function: AggregateFunction = "" diff --git a/test/fixtures/monitor_services_dbaas_metrics.json b/test/fixtures/monitor_services_dbaas_metrics.json new file mode 100644 index 000000000..67657cb78 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_metrics.json @@ -0,0 +1,47 @@ +{ + "data": { + "result": [ + { + "metric": { + "entity_id": 13316, + "metric_name": "avg_read_iops", + "node_id": "primary-9" + }, + "values": [ + [ + 1728996500, + "90.55555555555556" + ], + [ + 1729043400, + "14890.583333333334" + ] + ] + }, + { + "metric": { + "entity_id": 13217, + "metric_name": "avg_cpu_usage", + "node_id": "primary-0" + }, + "values": [ + [ + 1728996500, + "12.45" + ], + [ + 1729043400, + "18.67" + ] + ] + } + ], + "resultType": "matrix" + }, + "isPartial": false, + "stats": { + "executionTimeMsec": 21, + "seriesFetched": "2" + }, + "status": "success" +} \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 8c7d44a57..0a0566775 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -5,15 +5,21 @@ from test.integration.helpers import ( get_test_label, send_request_when_resource_available, + wait_for_condition, ) +from test.integration.models.database.helpers import get_db_engine_id from typing import Optional, Set import pytest import requests from requests.exceptions import ConnectionError, RequestException -from linode_api4 import PlacementGroupPolicy, PlacementGroupType -from linode_api4.linode_client import LinodeClient +from linode_api4 import ( + PlacementGroupPolicy, + PlacementGroupType, + PostgreSQLDatabase, +) +from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.objects import Region ENV_TOKEN_NAME = "LINODE_TOKEN" @@ -521,3 +527,68 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): yield linode_instance linode_instance.delete() + + +@pytest.fixture(scope="session") +def test_create_postgres_db(test_linode_client): + client = test_linode_client + label = get_test_label() + "-postgresqldb" + region = "us-ord" + engine_id = get_db_engine_id(client, "postgresql") + dbtype = "g6-standard-1" + + db = client.database.postgresql_create( + label=label, + region=region, + engine=engine_id, + ltype=dbtype, + cluster_size=None, + ) + + def get_db_status(): + return db.status == "active" + + # TAKES 15-30 MINUTES TO FULLY PROVISION DB + wait_for_condition(60, 2000, get_db_status) + + yield db + + send_request_when_resource_available(300, db.delete) + + +@pytest.fixture(scope="session") +def get_monitor_token_for_db_entities(test_linode_client): + client = test_linode_client + + dbs = client.database.postgresql_instances() + + if len(dbs) < 1: + db_id = test_create_postgres_db.id + else: + db_id = dbs[0].id + + region = client.load(PostgreSQLDatabase, db_id).region + dbs = client.database.instances() + + # only collect entity_ids in the same region + entity_ids = [db.id for db in dbs if db.region == region] + + # create token for the particular service + token = client.monitor.create_token( + service_type="dbaas", entity_ids=entity_ids + ) + + yield token, entity_ids + + +@pytest.fixture(scope="session") +def test_monitor_client(get_monitor_token_for_db_entities): + api_ca_file = get_api_ca_file() + token, entity_ids = get_monitor_token_for_db_entities + + client = MonitorClient( + token.token, + ca_path=api_ca_file, + ) + + return client, entity_ids diff --git a/test/integration/models/monitor_api/test_monitor_api.py b/test/integration/models/monitor_api/test_monitor_api.py new file mode 100644 index 000000000..842a8c420 --- /dev/null +++ b/test/integration/models/monitor_api/test_monitor_api.py @@ -0,0 +1,12 @@ +def test_monitor_api_fetch_dbaas_metrics(test_monitor_client): + client, entity_ids = test_monitor_client + + metrics = client.metrics.fetch_metrics( + "dbaas", + entity_ids=entity_ids, + metrics=[{"name": "read_iops", "aggregate_function": "avg"}], + relative_time_duration={"unit": "hr", "value": 1}, + ) + + assert metrics.status == "success" + assert len(metrics.data.result) > 0 diff --git a/test/unit/base.py b/test/unit/base.py index e143f8f64..bc0ec2f08 100644 --- a/test/unit/base.py +++ b/test/unit/base.py @@ -4,7 +4,7 @@ from mock import patch -from linode_api4 import LinodeClient +from linode_api4 import LinodeClient, MonitorClient FIXTURES = TestFixtures() @@ -202,3 +202,29 @@ def mock_delete(self): mocked requests """ return MethodMock("delete", {}) + + +class MonitorClientBaseCase(TestCase): + def setUp(self): + self.client = MonitorClient("testing", base_url="/") + + self.get_patch = patch( + "linode_api4.linode_client.requests.Session.get", + side_effect=mock_get, + ) + self.get_patch.start() + + def tearDown(self): + self.get_patch.stop() + + def mock_post(self, return_dct): + """ + Returns a MethodMock mocking a POST. This should be used in a with + statement. + + :param return_dct: The JSON that should be returned from this POST + + :returns: A MethodMock object who will capture the parameters of the + mocked requests + """ + return MethodMock("post", return_dct) diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py new file mode 100644 index 000000000..c34db068f --- /dev/null +++ b/test/unit/groups/monitor_api_test.py @@ -0,0 +1,52 @@ +from test.unit.base import MonitorClientBaseCase + +from linode_api4.objects import AggregateFunction, EntityMetricOptions + + +class MonitorAPITest(MonitorClientBaseCase): + """ + Tests methods of the Monitor API group + """ + + def test_fetch_metrics(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/metrics" + with self.mock_post(url) as m: + metrics = self.client.metrics.fetch_metrics( + service_type, + entity_ids=[13217, 13316], + metrics=[ + EntityMetricOptions( + name="avg_read_iops", + aggregate_function=AggregateFunction("avg"), + ), + {"name": "avg_cpu_usage", "aggregate_function": "avg"}, + ], + relative_time_duration={"unit": "hr", "value": 1}, + ) + + # assert call data + assert m.call_url == url + assert m.call_data == { + "entity_ids": [13217, 13316], + "metrics": [ + {"name": "avg_read_iops", "aggregate_function": "avg"}, + {"name": "avg_cpu_usage", "aggregate_function": "avg"}, + ], + "relative_time_duration": {"unit": "hr", "value": 1}, + } + + # assert the metrics data + metric_data = metrics.data.result[0] + + assert metrics.data.resultType == "matrix" + assert metric_data.metric["entity_id"] == 13316 + assert metric_data.metric["metric_name"] == "avg_read_iops" + assert metric_data.metric["node_id"] == "primary-9" + assert metric_data.values[0][0] == 1728996500 + assert metric_data.values[0][1] == "90.55555555555556" + + assert metrics.status == "success" + assert metrics.stats.executionTimeMsec == 21 + assert metrics.stats.seriesFetched == "2" + assert not metrics.isPartial From e18c8f1d775df1a635b6eee7fbf5f26909aeefe3 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 15 Aug 2025 11:43:15 -0400 Subject: [PATCH 319/379] Project: Host Maintenance Policy (#582) * Added support for Host/VM Maintenance (#532) * Added support for changes in Account Events, Maintenance, and Settings * Added support for changes in Instance and added Maintenance group * Add docstring and fix imports * Updated maintenance_policy_id to maintenance_policy (#564) * fix * Fix failing unit tests * fix * integration test * address_PR_comments * Added v4beta notices (#578) * Added missing doc link * Added maintenance to LinodeClient * Addressed PR comments --------- Co-authored-by: vshanthe Co-authored-by: Vinay <143587840+vshanthe@users.noreply.github.com> --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/account.py | 2 +- linode_api4/groups/linode.py | 7 ++ linode_api4/groups/maintenance.py | 25 +++++ linode_api4/linode_client.py | 5 + linode_api4/objects/account.py | 11 +- linode_api4/objects/linode.py | 3 + test/fixtures/account_events_123.json | 56 +++++----- test/fixtures/account_maintenance.json | 58 +++++++--- test/fixtures/account_settings.json | 3 +- test/fixtures/linode_instances.json | 6 +- test/fixtures/maintenance_policies.json | 28 +++++ .../models/account/test_account.py | 25 +++++ test/integration/models/linode/test_linode.py | 44 ++++++++ .../models/maintenance/test_maintenance.py | 12 ++ test/unit/groups/linode_test.py | 14 +++ test/unit/linode_client_test.py | 103 +++++++++++++++++- test/unit/objects/account_test.py | 47 +++++++- test/unit/objects/linode_test.py | 3 + 19 files changed, 397 insertions(+), 56 deletions(-) create mode 100644 linode_api4/groups/maintenance.py create mode 100644 test/fixtures/maintenance_policies.json create mode 100644 test/integration/models/maintenance/test_maintenance.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 4096cd21c..6f87eeb65 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 .monitor import * from .monitor_api import * from .networking import * 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/groups/linode.py b/linode_api4/groups/linode.py index 48f0d43b6..4c4dbfdbf 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -153,6 +153,7 @@ def instance_create( int, ] ] = None, + maintenance_policy: Optional[str] = None, **kwargs, ): """ @@ -296,6 +297,11 @@ 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: The slug of the maintenance policy to apply during maintenance. + If not provided, the default policy (linode/migrate) will be applied. + NOTE: This field is in beta and may only + function if base_url is set to `https://api.linode.com/v4beta`. + :type maintenance_policy: str :returns: A new Instance object, or a tuple containing the new Instance and the generated password. @@ -327,6 +333,7 @@ def instance_create( "firewall_id": firewall, "backup_id": backup, "stackscript_id": stackscript, + "maintenance_policy": maintenance_policy, # 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..f41780dfb --- /dev/null +++ b/linode_api4/groups/maintenance.py @@ -0,0 +1,25 @@ +from linode_api4.groups import Group +from linode_api4.objects import MappedObject + + +class MaintenanceGroup(Group): + """ + Collections related to Maintenance. + """ + + def maintenance_policies(self): + """ + .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. + + Returns a collection of MaintenancePolicy objects representing + available maintenance policies that can be applied to Linodes + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-policies + + :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["data"]] diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index d1e35761e..1d9f0bba4 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -19,6 +19,7 @@ LinodeGroup, LKEGroup, LongviewGroup, + MaintenanceGroup, MetricsGroup, MonitorGroup, NetworkingGroup, @@ -399,6 +400,10 @@ def __init__( #: :any:`NetworkingGroup` for more information self.networking = NetworkingGroup(self) + #: Access methods related to maintenance on your account - see + #: :any:`MaintenanceGroup` for more information + self.maintenance = MaintenanceGroup(self) + #: Access methods related to support - see :any:`SupportGroup` for more #: information self.support = SupportGroup(self) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 836f41522..2ad1b6482 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -198,6 +198,9 @@ class AccountSettings(Base): ), "object_storage": Property(), "backups_enabled": Property(mutable=True), + "maintenance_policy": Property( + mutable=True + ), # Note: This field is only available when using v4beta. } @@ -220,12 +223,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(), # Note: This field is only available when using v4beta. + "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/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index c70dd7965..2d051fb44 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -686,6 +686,9 @@ class Instance(Base): "disk_encryption": Property(), "lke_cluster_id": Property(), "capabilities": Property(unordered=True), + "maintenance_policy": Property( + mutable=True + ), # Note: This field is only available when using v4beta. } @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..30f8ed19e 100644 --- a/test/fixtures/account_maintenance.json +++ b/test/fixtures/account_maintenance.json @@ -1,19 +1,41 @@ { - "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 -} \ No newline at end of file + "pages": 1, + "page": 1, + "results": 2, + "data": [ + { + "entity": { + "id": 1234, + "label": "Linode #1234", + "type": "linode", + "url": "/linodes/1234" + }, + "reason": "Scheduled upgrade to faster NVMe hardware.", + "type": "linode_migrate", + "maintenance_policy_set": "linode/power_off_on", + "description": "Scheduled Maintenance", + "source": "platform", + "not_before": "2025-03-25T10:00:00Z", + "start_time": "2025-03-25T12:00:00Z", + "complete_time": "2025-03-25T14:00:00Z", + "status": "scheduled" + }, + { + "entity": { + "id": 1234, + "label": "Linode #1234", + "type": "linode", + "url": "/linodes/1234" + }, + "reason": "Pending migration of Linode #1234 to a new host.", + "type": "linode_migrate", + "maintenance_policy_set": "linode/migrate", + "description": "Emergency Maintenance", + "source": "user", + "not_before": "2025-03-26T15:00:00Z", + "start_time": "2025-03-26T15:00:00Z", + "complete_time": "2025-03-26T17:00:00Z", + "status": "in-progress" + } + ] +} diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json index 77a2fdac3..dda69f1ab 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": "linode/migrate" } diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 38a3cf912..452fc354d 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" : "linode/migrate" }, { "group": "test", @@ -90,7 +91,8 @@ "watchdog_enabled": false, "disk_encryption": "enabled", "lke_cluster_id": 18881, - "placement_group": null + "placement_group": null, + "maintenance_policy" : "linode/power_off_on" } ] } diff --git a/test/fixtures/maintenance_policies.json b/test/fixtures/maintenance_policies.json new file mode 100644 index 000000000..409255a07 --- /dev/null +++ b/test/fixtures/maintenance_policies.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "slug": "linode/migrate", + "label": "Migrate", + "description": "Migrates the Linode to a new host while it remains fully operational. Recommended for maximizing availability.", + "type": "migrate", + "notification_period_sec": 3600, + "is_default": true + }, + { + "slug": "linode/power_off_on", + "label": "Power Off/Power On", + "description": "Powers off the Linode at the start of the maintenance event and reboots it once the maintenance finishes. Recommended for maximizing performance.", + "type": "power_off_on", + "notification_period_sec": 1800, + "is_default": false + }, + { + "slug": "private/12345", + "label": "Critical Workload - Avoid Migration", + "description": "Custom policy designed to power off and perform maintenance during user-defined windows only.", + "type": "power_off_on", + "notification_period_sec": 7200, + "is_default": false + } + ] +} \ No newline at end of file diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 72cd97cda..805f713b6 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -59,6 +59,31 @@ def test_get_account_settings(test_linode_client): assert "longview_subscription" in str(account_settings._raw_json) assert "backups_enabled" in str(account_settings._raw_json) assert "object_storage" in str(account_settings._raw_json) + assert "maintenance_policy" in str(account_settings._raw_json) + + +def test_update_maintenance_policy(test_linode_client): + client = test_linode_client + settings = client.load(AccountSettings(client, ""), "") + + original_policy = settings.maintenance_policy + new_policy = ( + "linode/power_off_on" + if original_policy == "linode/migrate" + else "linode/migrate" + ) + + settings.maintenance_policy = new_policy + settings.save() + + updated = client.load(AccountSettings(client, ""), "") + assert updated.maintenance_policy == new_policy + + settings.maintenance_policy = original_policy + settings.save() + + updated = client.load(AccountSettings(client, ""), "") + assert updated.maintenance_policy == original_policy @pytest.mark.smoke diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 97965f2b9..77af1e218 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -877,3 +877,47 @@ def test_delete_interface_containing_vpc( # returns true when delete successful assert result + + +def test_create_linode_with_maintenance_policy(test_linode_client): + client = test_linode_client + region = get_region(client, {"Linodes"}, site_type="core") + label = get_test_label() + + policies = client.maintenance.maintenance_policies() + assert policies, "No maintenance policies returned from API" + + non_default_policy = next((p for p in policies if not p.is_default), None) + assert non_default_policy, "No non-default maintenance policy available" + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label + "_with_policy", + maintenance_policy_id=non_default_policy.slug, + ) + + assert linode_instance.id is not None + assert linode_instance.label.startswith(label) + assert linode_instance.maintenance_policy == non_default_policy.slug + + linode_instance.delete() + + +def test_update_linode_maintenance_policy(create_linode, test_linode_client): + client = test_linode_client + linode = create_linode + + policies = client.maintenance.maintenance_policies() + assert policies, "No maintenance policies returned from API" + + non_default_policy = next((p for p in policies if not p.is_default), None) + assert non_default_policy, "No non-default maintenance policy found" + + linode.maintenance_policy_id = non_default_policy.slug + result = linode.save() + + linode.invalidate() + assert result + assert linode.maintenance_policy_id == non_default_policy.slug diff --git a/test/integration/models/maintenance/test_maintenance.py b/test/integration/models/maintenance/test_maintenance.py new file mode 100644 index 000000000..509d06cf6 --- /dev/null +++ b/test/integration/models/maintenance/test_maintenance.py @@ -0,0 +1,12 @@ +def test_get_maintenance_policies(test_linode_client): + client = test_linode_client + + policies = client.maintenance.maintenance_policies() + + assert isinstance(policies, list) + assert all(hasattr(p, "slug") for p in policies) + + slugs = [p.slug for p in policies] + assert any( + slug in slugs for slug in ["linode/migrate", "linode/power_off_on"] + ) diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index 8112a5d93..8a6697c81 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_maintenance_policy(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="linode/migrate", + ) + + self.assertEqual(m.call_data["maintenance_policy"], "linode/migrate") + 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 c79c0a88d..3c33a16f2 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -307,6 +307,59 @@ 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 + 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), 3) + + policy_migrate = result[0] + policy_power_off_on = result[1] + policy_custom = result[2] + + self.assertEqual(policy_migrate.slug, "linode/migrate") + self.assertEqual(policy_migrate.label, "Migrate") + self.assertEqual( + policy_migrate.description, + "Migrates the Linode to a new host while it remains fully operational. Recommended for maximizing availability.", + ) + self.assertEqual(policy_migrate.type, "migrate") + self.assertEqual(policy_migrate.notification_period_sec, 3600) + self.assertTrue(policy_migrate.is_default) + + self.assertEqual(policy_power_off_on.slug, "linode/power_off_on") + self.assertEqual(policy_power_off_on.label, "Power Off/Power On") + self.assertEqual( + policy_power_off_on.description, + "Powers off the Linode at the start of the maintenance event and reboots it once the maintenance finishes. Recommended for maximizing performance.", + ) + self.assertEqual(policy_power_off_on.type, "power_off_on") + self.assertEqual(policy_power_off_on.notification_period_sec, 1800) + self.assertFalse(policy_power_off_on.is_default) + + self.assertEqual(policy_custom.slug, "private/12345") + self.assertEqual( + policy_custom.label, "Critical Workload - Avoid Migration" + ) + self.assertEqual( + policy_custom.description, + "Custom policy designed to power off and perform maintenance during user-defined windows only.", + ) + self.assertEqual(policy_custom.type, "power_off_on") + self.assertEqual(policy_custom.notification_period_sec, 7200) + self.assertFalse(policy_custom.is_default) + + class AccountGroupTest(ClientBaseCase): """ Tests methods of the AccountGroup @@ -353,12 +406,56 @@ 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) + self.assertEqual(len(result), 2) + + maintenance_1 = result[0] + maintenance_2 = result[1] + + # First maintenance + self.assertEqual( + maintenance_1.reason, + "Scheduled upgrade to faster NVMe hardware.", + ) + self.assertEqual(maintenance_1.entity.id, 1234) + self.assertEqual(maintenance_1.entity.label, "Linode #1234") + self.assertEqual(maintenance_1.entity.type, "linode") + self.assertEqual(maintenance_1.entity.url, "/linodes/1234") + self.assertEqual( + maintenance_1.maintenance_policy_set, "linode/power_off_on" + ) + self.assertEqual(maintenance_1.description, "Scheduled Maintenance") + self.assertEqual(maintenance_1.source, "platform") + self.assertEqual(maintenance_1.not_before, "2025-03-25T10:00:00Z") + self.assertEqual(maintenance_1.start_time, "2025-03-25T12:00:00Z") + self.assertEqual( + maintenance_1.complete_time, "2025-03-25T14:00:00Z" + ) + self.assertEqual(maintenance_1.status, "scheduled") + self.assertEqual(maintenance_1.type, "linode_migrate") + + # Second maintenance + self.assertEqual( + maintenance_2.reason, + "Pending migration of Linode #1234 to a new host.", + ) + self.assertEqual(maintenance_2.entity.id, 1234) + self.assertEqual(maintenance_2.entity.label, "Linode #1234") + self.assertEqual(maintenance_2.entity.type, "linode") + self.assertEqual(maintenance_2.entity.url, "/linodes/1234") + self.assertEqual( + maintenance_2.maintenance_policy_set, "linode/migrate" + ) + self.assertEqual(maintenance_2.description, "Emergency Maintenance") + self.assertEqual(maintenance_2.source, "user") + self.assertEqual(maintenance_2.not_before, "2025-03-26T15:00:00Z") + self.assertEqual(maintenance_2.start_time, "2025-03-26T15:00:00Z") self.assertEqual( - result[0].reason, - "This maintenance will allow us to update the BIOS on the host's motherboard.", + maintenance_2.complete_time, "2025-03-26T17:00:00Z" ) + self.assertEqual(maintenance_2.status, "in-progress") + self.assertEqual(maintenance_2.type, "linode_migrate") def test_notifications(self): """ diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 1f9da98fb..c55587adb 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, "linode/migrate") + + 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 = "linode/migrate" + settings.save() + + self.assertEqual(m.call_url, "/account/settings") + self.assertEqual( + m.call_data, + { + "maintenance_policy": "linode/migrate", + }, + ) 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 diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 6016d2776..8fa3cdbb3 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, "linode/migrate") 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 = "linode/power_off_on" 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": "linode/power_off_on", }, ) From 59188246548ecf1691bdaf36a8fc2672da3cbd82 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 15 Aug 2025 14:42:15 -0400 Subject: [PATCH 320/379] Fixed maintenance policy test (#584) * Fixed maintenance policy test * Add note to replace region in GA --- test/integration/models/linode/test_linode.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 77af1e218..7b3e836d1 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -881,7 +881,8 @@ def test_delete_interface_containing_vpc( def test_create_linode_with_maintenance_policy(test_linode_client): client = test_linode_client - region = get_region(client, {"Linodes"}, site_type="core") + # TODO: Replace with random region after GA + region = "eu-central" label = get_test_label() policies = client.maintenance.maintenance_policies() From b66a974234e355bd12f564c0b5a44dfd7394d0a7 Mon Sep 17 00:00:00 2001 From: Vinay <143587840+vshanthe@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:32:20 +0530 Subject: [PATCH 321/379] fix (#585) --- test/integration/models/linode/test_linode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 7b3e836d1..52d948d26 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -882,7 +882,7 @@ def test_delete_interface_containing_vpc( def test_create_linode_with_maintenance_policy(test_linode_client): client = test_linode_client # TODO: Replace with random region after GA - region = "eu-central" + region = "ap-south" label = get_test_label() policies = client.maintenance.maintenance_policies() @@ -896,7 +896,7 @@ def test_create_linode_with_maintenance_policy(test_linode_client): region, image="linode/debian12", label=label + "_with_policy", - maintenance_policy_id=non_default_policy.slug, + maintenance_policy=non_default_policy.slug, ) assert linode_instance.id is not None From 4825c2b1c3444da836b69897b977aa09bd985c25 Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Mon, 18 Aug 2025 11:24:06 -0400 Subject: [PATCH 322/379] Fix monitor-api integration test (#586) * fix test * lint --- test/integration/conftest.py | 4 +++- test/integration/models/monitor_api/test_monitor_api.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 0a0566775..75f0c8f5d 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -557,7 +557,9 @@ def get_db_status(): @pytest.fixture(scope="session") -def get_monitor_token_for_db_entities(test_linode_client): +def get_monitor_token_for_db_entities( + test_linode_client, test_create_postgres_db +): client = test_linode_client dbs = client.database.postgresql_instances() diff --git a/test/integration/models/monitor_api/test_monitor_api.py b/test/integration/models/monitor_api/test_monitor_api.py index 842a8c420..d9fd755b3 100644 --- a/test/integration/models/monitor_api/test_monitor_api.py +++ b/test/integration/models/monitor_api/test_monitor_api.py @@ -9,4 +9,3 @@ def test_monitor_api_fetch_dbaas_metrics(test_monitor_client): ) assert metrics.status == "success" - assert len(metrics.data.result) > 0 From 0c32726f3ac35dcd4a2dc8fb70e03e9ce0066fe4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 02:30:24 -0400 Subject: [PATCH 323/379] build(deps): bump actions/checkout from 4 to 5 (#579) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/e2e-test-pr.yml | 4 ++-- .github/workflows/e2e-test.yml | 8 ++++---- .github/workflows/labeler.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/publish-pypi.yaml | 2 +- .github/workflows/release-cross-repo-test.yml | 4 ++-- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fd2ad747..d9863f1fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: setup python 3 uses: actions/setup-python@v5 @@ -33,7 +33,7 @@ jobs: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7168ea488..527950d61 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f2b7117d8..e31dcc975 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout repository' - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 with: diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 31e695aca..2f26c393b 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -48,7 +48,7 @@ jobs: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ inputs.sha }} fetch-depth: 0 @@ -150,7 +150,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 1c4ec8540..3c9c531fd 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -57,7 +57,7 @@ jobs: steps: - name: Clone Repository with SHA if: ${{ inputs.sha != '' }} - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -65,7 +65,7 @@ jobs: - name: Clone Repository without SHA if: ${{ inputs.sha == '' }} - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -111,7 +111,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -178,7 +178,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 30bcb1956..7a3ee5f37 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index dc41e1600..2d7f9543a 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: dev diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 027ac5298..a921010ca 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -12,7 +12,7 @@ jobs: environment: pypi-release steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Python uses: actions/setup-python@v5 diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 052eaffb4..e6ca88cd3 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout linode_api4 repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 submodules: 'recursive' @@ -30,7 +30,7 @@ jobs: python-version: '3.10' - name: Checkout ansible repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: linode/ansible_linode path: .ansible/collections/ansible_collections/linode/cloud From f2055c68e703de1f124c2f80cfce8ea2fc897c8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Aug 2025 02:31:32 -0400 Subject: [PATCH 324/379] build(deps): bump actions/download-artifact from 4 to 5 (#580) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 3c9c531fd..7a94a1e24 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -184,7 +184,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: test-report-file From 78ae6187d38d648bf661ad3ccfed2d4acc205a7f Mon Sep 17 00:00:00 2001 From: rammanoj Date: Fri, 22 Aug 2025 14:13:56 -0400 Subject: [PATCH 325/379] Add label to nodepool (#588) * add label to nodepool * remove redundant prints --------- Co-authored-by: rpotla --- linode_api4/objects/lke.py | 12 +++++------- test/fixtures/lke_clusters_18881_pools_456.json | 1 + test/fixtures/lke_clusters_18882_pools_789.json | 1 + test/unit/objects/lke_test.py | 5 +++++ 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 7086b1113..792aed988 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -187,6 +187,7 @@ class LKENodePool(DerivedBase): properties = { "id": Property(identifier=True), "cluster_id": Property(identifier=True), + "label": Property(mutable=True), "type": Property(slug_relationship=Type), "disks": Property(), "disk_encryption": Property(), @@ -419,6 +420,7 @@ def node_pool_create( Union[str, KubeVersion, TieredKubeVersion] ] = None, update_strategy: Optional[str] = None, + label: str = None, **kwargs, ): """ @@ -444,23 +446,19 @@ def node_pool_create( for possible values. :returns: The new Node Pool + :param label: The name of the node pool. + :type label: str :rtype: LKENodePool """ params = { "type": node_type, + "label": label, "count": node_count, "labels": labels, "taints": taints, "k8s_version": k8s_version, "update_strategy": update_strategy, } - - if labels is not None: - params["labels"] = labels - - if taints is not None: - params["taints"] = taints - params.update(kwargs) result = self._client.post( diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index f904b9c95..9aa5fb0f0 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -34,6 +34,7 @@ "foo": "bar", "bar": "foo" }, + "label": "example-node-pool", "type": "g6-standard-4", "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18882_pools_789.json b/test/fixtures/lke_clusters_18882_pools_789.json index a7bbc4749..d3c17eedb 100644 --- a/test/fixtures/lke_clusters_18882_pools_789.json +++ b/test/fixtures/lke_clusters_18882_pools_789.json @@ -1,6 +1,7 @@ { "id": 789, "type": "g6-standard-2", + "label": "enterprise-node-pool", "count": 3, "nodes": [], "disks": [], diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index a0ad63288..cb9589cfb 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -51,6 +51,7 @@ def test_get_pool(self): assert pool.id == 456 assert pool.cluster_id == 18881 assert pool.type.id == "g6-standard-4" + assert pool.label == "example-node-pool" assert pool.disk_encryption == InstanceDiskEncryptionType.enabled assert pool.disks is not None @@ -162,6 +163,7 @@ def test_load_node_pool(self): self.assertEqual(pool.id, 456) self.assertEqual(pool.cluster_id, 18881) self.assertEqual(pool.type.id, "g6-standard-4") + self.assertEqual(pool.label, "example-node-pool") self.assertIsNotNone(pool.disks) self.assertIsNotNone(pool.nodes) self.assertIsNotNone(pool.autoscaler) @@ -251,6 +253,7 @@ def test_lke_node_pool_update(self): pool.tags = ["foobar"] pool.count = 5 + pool.label = "testing-label" pool.autoscaler = { "enabled": True, "min": 2, @@ -274,6 +277,7 @@ def test_lke_node_pool_update(self): "min": 2, "max": 10, }, + "label": "testing-label", "labels": { "updated-key": "updated-value", }, @@ -546,6 +550,7 @@ def test_cluster_enterprise(self): pool = LKENodePool(self.client, 789, 18882) assert pool.k8s_version == "1.31.1+lke1" assert pool.update_strategy == "rolling_update" + assert pool.label == "enterprise-node-pool" def test_lke_tiered_version(self): version = TieredKubeVersion(self.client, "1.32", "standard") From 0c1f2b855f204619d679c0828c77cfc5cc427e6b Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 5 Sep 2025 16:13:04 -0400 Subject: [PATCH 326/379] Updated incorrect documentation link for maintenance policies (#590) --- linode_api4/groups/maintenance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/groups/maintenance.py b/linode_api4/groups/maintenance.py index f41780dfb..7d56cec6e 100644 --- a/linode_api4/groups/maintenance.py +++ b/linode_api4/groups/maintenance.py @@ -14,7 +14,7 @@ def maintenance_policies(self): Returns a collection of MaintenancePolicy objects representing available maintenance policies that can be applied to Linodes - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-policies + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-maintenance-policies :returns: A list of Maintenance Policies that can be applied to Linodes :rtype: List of MaintenancePolicy objects as MappedObjects From 8b85487e49da429b5063888b7f9c29a82a4ededb Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 8 Sep 2025 09:54:36 -0400 Subject: [PATCH 327/379] Resolve various integration test failures (#591) * Various test fixes to unblock upcoming release * Use dynamic label for VLAN test * oops * LA Disk Encryption -> Disk Encryption --- test/integration/filters/fixtures.py | 4 +- test/integration/models/image/test_image.py | 40 ++++++++++++++----- test/integration/models/linode/test_linode.py | 4 +- test/integration/models/lke/test_lke.py | 10 ++--- .../models/networking/test_networking.py | 2 +- .../models/nodebalancer/test_nodebalancer.py | 13 ++++-- 6 files changed, 47 insertions(+), 26 deletions(-) diff --git a/test/integration/filters/fixtures.py b/test/integration/filters/fixtures.py index 344303eee..31b7edcbf 100644 --- a/test/integration/filters/fixtures.py +++ b/test/integration/filters/fixtures.py @@ -23,9 +23,7 @@ def lke_cluster_instance(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = get_region( - test_linode_client, {"Kubernetes", "LA Disk Encryption"} - ) + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 9124ddf97..58da0a56f 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -1,22 +1,46 @@ from io import BytesIO -from test.integration.conftest import get_region, get_regions +from test.integration.conftest import get_regions from test.integration.helpers import get_test_label import polling import pytest +from linode_api4 import LinodeClient from linode_api4.objects import Image +DISALLOWED_IMAGE_REGIONS = { + "gb-lon", + "au-mel", + "sg-sin-2", + "jp-tyo-3", +} + + +def get_image_upload_regions(client: LinodeClient): + """ + This is necessary because the API does not currently expose + a capability for regions that allow custom image uploads. + + In the future, we should remove this if the API exposes a custom images capability or + if all Object Storage regions support custom images. + """ + + return [ + region + for region in get_regions( + client, + capabilities={"Linodes", "Object Storage"}, + site_type="core", + ) + if region.id not in DISALLOWED_IMAGE_REGIONS + ] + @pytest.fixture(scope="session") def image_upload_url(test_linode_client): label = get_test_label() + "_image" - region = get_region( - test_linode_client, - capabilities={"Linodes", "Object Storage"}, - site_type="core", - ) + region = get_image_upload_regions(test_linode_client)[0] test_linode_client.image_create_upload( label, region.id, "integration test image upload" @@ -38,9 +62,7 @@ def test_uploaded_image(test_linode_client): label = get_test_label() + "_image" - regions = get_regions( - test_linode_client, capabilities={"Object Storage"}, site_type="core" - ) + regions = get_image_upload_regions(test_linode_client) image = test_linode_client.image_upload( label, diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 52d948d26..e13903e4f 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -180,7 +180,7 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): def linode_with_disk_encryption(test_linode_client, request): client = test_linode_client - target_region = get_region(client, {"LA Disk Encryption"}) + target_region = get_region(client, {"Disk Encryption"}) label = get_test_label(length=8) disk_encryption = request.param @@ -235,7 +235,7 @@ def test_linode_transfer(test_linode_client, linode_with_volume_firewall): def test_linode_rebuild(test_linode_client): client = test_linode_client - region = get_region(client, {"LA Disk Encryption"}) + region = get_region(client, {"Disk Encryption"}) label = get_test_label() + "_rebuild" diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 3486485d6..241117442 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -32,9 +32,7 @@ def lke_cluster(test_linode_client): node_type = test_linode_client.linode.types()[1] # g6-standard-1 version = test_linode_client.lke.versions()[0] - region = get_region( - test_linode_client, {"Kubernetes", "LA Disk Encryption"} - ) + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) node_pools = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" @@ -117,9 +115,7 @@ def lke_cluster_with_labels_and_taints(test_linode_client): def lke_cluster_with_apl(test_linode_client): version = test_linode_client.lke.versions()[0] - region = get_region( - test_linode_client, {"Kubernetes", "LA Disk Encryption"} - ) + region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) # NOTE: g6-dedicated-4 is the minimum APL-compatible Linode type node_pools = test_linode_client.lke.node_pool("g6-dedicated-4", 3) @@ -149,7 +145,7 @@ def lke_cluster_enterprise(test_linode_client): )[0] region = get_region( - test_linode_client, {"Kubernetes Enterprise", "LA Disk Encryption"} + test_linode_client, {"Kubernetes Enterprise", "Disk Encryption"} ) node_pools = test_linode_client.lke.node_pool( diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index b92cdfadc..87a0e5842 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -235,7 +235,7 @@ def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): config.interfaces = [] config.save() - vlan_label = "testvlan" + vlan_label = f"{get_test_label(8)}-testvlan" interface = config.interface_create_vlan( label=vlan_label, ipam_address="10.0.0.2/32" ) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index df07de215..9e7537897 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -167,7 +167,9 @@ def test_update_nb(test_linode_client, create_nb): create_nb.id, ) - nb.label = "ThisNewLabel" + new_label = f"{nb.label}-ThisNewLabel" + + nb.label = new_label nb.client_udp_sess_throttle = 5 nb.save() @@ -176,7 +178,7 @@ def test_update_nb(test_linode_client, create_nb): create_nb.id, ) - assert "ThisNewLabel" == nb_updated.label + assert new_label == nb_updated.label assert 5 == nb_updated.client_udp_sess_throttle @@ -215,7 +217,10 @@ def test_update_nb_node(test_linode_client, create_nb_config): create_nb_config.nodebalancer_id, ) node = config.nodes[0] - node.label = "ThisNewLabel" + + new_label = f"{node.label}-ThisNewLabel" + + node.label = new_label node.weight = 50 node.mode = "accept" node.save() @@ -226,7 +231,7 @@ def test_update_nb_node(test_linode_client, create_nb_config): (create_nb_config.id, create_nb_config.nodebalancer_id), ) - assert "ThisNewLabel" == node_updated.label + assert new_label == node_updated.label assert 50 == node_updated.weight assert "accept" == node_updated.mode From 2d3027728e6e637e140db40caba545b7d9b1ca2b Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:43:39 -0400 Subject: [PATCH 328/379] Add no-osl-1 to image test disallow list (#593) --- test/integration/models/image/test_image.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index 58da0a56f..18e223ff0 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -13,6 +13,7 @@ "au-mel", "sg-sin-2", "jp-tyo-3", + "no-osl-1", } From 4f8ba4c7f7570b74b180671d276322685e1962d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:49:33 +0530 Subject: [PATCH 329/379] build(deps): bump actions/setup-python from 5 to 6 (#595) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/e2e-test-pr.yml | 4 ++-- .github/workflows/e2e-test.yml | 6 +++--- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/publish-pypi.yaml | 2 +- .github/workflows/release-cross-repo-test.yml | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9863f1fd..c665358d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v5 - name: setup python 3 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -34,7 +34,7 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Run tests diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 2f26c393b..1e12c7475 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -80,7 +80,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -176,7 +176,7 @@ jobs: steps: - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 7a94a1e24..282914ebc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -71,7 +71,7 @@ jobs: submodules: 'recursive' - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} @@ -141,7 +141,7 @@ jobs: steps: - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' @@ -189,7 +189,7 @@ jobs: name: test-report-file - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index 2d7f9543a..d905a1265 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -24,7 +24,7 @@ jobs: ref: dev - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index a921010ca..85f142bc6 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index e6ca88cd3..62f4bea47 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -25,7 +25,7 @@ jobs: run: sudo apt-get install -y build-essential - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.10' From 656409fcc64f621f334946f2d0f9146e6551c7df Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:45:55 -0400 Subject: [PATCH 330/379] Remove add_placement_groups from tests (#597) --- test/unit/objects/account_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index c55587adb..3e04bcaca 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -379,7 +379,6 @@ def test_user_grants_serialization(): "add_linodes": True, "add_longview": True, "add_nodebalancers": True, - "add_placement_groups": True, "add_stackscripts": True, "add_volumes": True, "add_vpcs": True, From 2dfbadfa4f8cbfc1246d6d52cec23b2f991784bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:20:25 -0400 Subject: [PATCH 331/379] build(deps): bump actions/github-script from 7 to 8 (#596) Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/e2e-test-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 1e12c7475..e5973ebbe 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -115,7 +115,7 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} - - uses: actions/github-script@v7 + - uses: actions/github-script@v8 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: From 3d53e901d6984ec22478c6267f41086e0eb1e84f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:58:50 -0400 Subject: [PATCH 332/379] build(deps): bump pypa/gh-action-pypi-publish from 1.12.4 to 1.13.0 (#594) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.12.4 to 1.13.0. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/76f52bc884231f62b9a034ebfe128415bbaabdfc...ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.13.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish-pypi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 85f142bc6..87a002bbe 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -28,4 +28,4 @@ jobs: LINODE_SDK_VERSION: ${{ github.event.release.tag_name }} - name: Publish the release artifacts to PyPI - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # pin@release/v1.12.4 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # pin@release/v1.13.0 From 195e2cda5a0067e489869d0ff3a89e58659cf191 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 19 Sep 2025 09:08:37 -0400 Subject: [PATCH 333/379] Made longview_subscription immutable in Account Settings (#581) --- linode_api4/objects/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 2ad1b6482..5cf564bff 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -194,7 +194,7 @@ class AccountSettings(Base): "network_helper": Property(mutable=True), "managed": Property(), "longview_subscription": Property( - slug_relationship=LongviewSubscription + slug_relationship=LongviewSubscription, mutable=False ), "object_storage": Property(), "backups_enabled": Property(mutable=True), From 58a6eb03008b35ae8687d1badd2556da2ff6c4f8 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:01:46 -0400 Subject: [PATCH 334/379] Project: Linode Interfaces (#540) * Enhanced Interfaces: Add support for Firewall templates (#529) * Add support for Firewall Templates * oops * Add LA notices * Enhanced Interfaces: Add account-related fields (#525) * Enhanced Interfaces: Add account-related fields * Add setting enum * Add LA notice * Drop residual print * Enhanced Interfaces: Implement endpoints & fields related to VPCs and non-interface networking (#526) * Implement endpoints & fields related to VPCs and non-interface networking * Add LA notices * Enhanced Interfaces: Add support for Linode-related endpoints and fields (#533) * Add support for Linode-related endpoints and fields * oops * tiny fixes * fix docsa * Add docs examples * Docs fixes * oops * Remove irrelevant test * Add LA notices * Fill in API documentation URLs * Add return types * Enable `include_none_values` in FirewallSettingsDefaultFirewallIDs (#558) * Linode Interfaces: Allow specifying ExplicitNullValue for LinodeInterfaceOptions firewall ID (#565) * Add ExplicitNullValue support * Fix failing integration tests * Add unit tests * Add docs disclaimer * Fix * Update test/fixtures/linode_instances.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/unit/objects/linode_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/conftest.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/objects/linode.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * More fixes * lint * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove unnecessary local imports * Fix IPv6 addresses * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/unit/objects/networking_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/unit/objects/linode_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Co-authored-by: Lena Garber Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/groups/linode.py | 56 +- linode_api4/groups/networking.py | 120 ++++- linode_api4/objects/__init__.py | 1 + linode_api4/objects/account.py | 20 + linode_api4/objects/base.py | 1 + linode_api4/objects/linode.py | 253 +++++++++- linode_api4/objects/linode_interfaces.py | 477 ++++++++++++++++++ linode_api4/objects/networking.py | 96 +++- linode_api4/objects/serializable.py | 10 + linode_api4/objects/vpc.py | 1 + test/fixtures/account.json | 3 +- test/fixtures/account_settings.json | 1 + test/fixtures/linode_instances.json | 49 +- test/fixtures/linode_instances_124.json | 43 ++ .../linode_instances_124_interfaces.json | 103 ++++ .../linode_instances_124_interfaces_123.json | 53 ++ ...nstances_124_interfaces_123_firewalls.json | 56 ++ .../linode_instances_124_interfaces_456.json | 28 + .../linode_instances_124_interfaces_789.json | 14 + ...ode_instances_124_interfaces_settings.json | 16 + ...node_instances_124_upgrade-interfaces.json | 105 ++++ .../networking_firewalls_123_devices.json | 13 +- .../networking_firewalls_123_devices_456.json | 11 + .../networking_firewalls_settings.json | 8 + .../networking_firewalls_templates.json | 93 ++++ ...networking_firewalls_templates_public.json | 43 ++ .../networking_firewalls_templates_vpc.json | 43 ++ test/fixtures/networking_ips_127.0.0.1.json | 1 + test/fixtures/regions.json | 33 +- test/fixtures/vpcs_123456_subnets.json | 6 +- test/fixtures/vpcs_123456_subnets_789.json | 6 +- test/integration/conftest.py | 78 +++ .../linode_client/test_linode_client.py | 6 +- .../models/account/test_account.py | 1 + .../firewall/test_firewall_templates.py | 33 ++ .../linode/interfaces/test_interfaces.py | 343 +++++++++++++ test/integration/models/linode/test_linode.py | 194 ++++++- .../models/networking/test_networking.py | 87 +++- test/unit/groups/linode_test.py | 41 +- test/unit/groups/networking_test.py | 17 + test/unit/linode_client_test.py | 71 ++- test/unit/objects/account_test.py | 29 +- test/unit/objects/firewall_test.py | 79 ++- test/unit/objects/linode_interface_test.py | 307 +++++++++++ test/unit/objects/linode_test.py | 161 +++++- test/unit/objects/networking_test.py | 25 +- test/unit/objects/region_test.py | 4 +- test/unit/objects/serializable_test.py | 16 +- test/unit/objects/vpc_test.py | 18 +- 49 files changed, 3189 insertions(+), 84 deletions(-) create mode 100644 linode_api4/objects/linode_interfaces.py create mode 100644 test/fixtures/linode_instances_124.json create mode 100644 test/fixtures/linode_instances_124_interfaces.json create mode 100644 test/fixtures/linode_instances_124_interfaces_123.json create mode 100644 test/fixtures/linode_instances_124_interfaces_123_firewalls.json create mode 100644 test/fixtures/linode_instances_124_interfaces_456.json create mode 100644 test/fixtures/linode_instances_124_interfaces_789.json create mode 100644 test/fixtures/linode_instances_124_interfaces_settings.json create mode 100644 test/fixtures/linode_instances_124_upgrade-interfaces.json create mode 100644 test/fixtures/networking_firewalls_123_devices_456.json create mode 100644 test/fixtures/networking_firewalls_settings.json create mode 100644 test/fixtures/networking_firewalls_templates.json create mode 100644 test/fixtures/networking_firewalls_templates_public.json create mode 100644 test/fixtures/networking_firewalls_templates_vpc.json create mode 100644 test/integration/models/firewall/test_firewall_templates.py create mode 100644 test/integration/models/linode/interfaces/test_interfaces.py create mode 100644 test/unit/groups/networking_test.py create mode 100644 test/unit/objects/linode_interface_test.py diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 4c4dbfdbf..e12e9cf48 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,13 +1,11 @@ import base64 import os -from collections.abc import Iterable -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union from linode_api4.common import load_and_validate_keys from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( - ConfigInterface, Firewall, Instance, InstanceDiskEncryptionType, @@ -21,8 +19,13 @@ from linode_api4.objects.linode import ( Backup, InstancePlacementGroupAssignment, + InterfaceGeneration, + NetworkInterface, _expand_placement_group_assignment, ) +from linode_api4.objects.linode_interfaces import ( + LinodeInterfaceOptions, +) from linode_api4.util import drop_null_keys @@ -153,6 +156,13 @@ def instance_create( int, ] ] = None, + interfaces: Optional[ + List[ + Union[LinodeInterfaceOptions, NetworkInterface, Dict[str, Any]], + ] + ] = None, + interface_generation: Optional[Union[InterfaceGeneration, str]] = None, + network_helper: Optional[bool] = None, maintenance_policy: Optional[str] = None, **kwargs, ): @@ -231,6 +241,30 @@ def instance_create( "us-east", backup=snapshot) + **Create an Instance with explicit interfaces:** + + To create a new Instance with explicit interfaces, provide list of + LinodeInterfaceOptions objects or dicts to the "interfaces" field:: + + linode, password = client.linode.instance_create( + "g6-standard-1", + "us-mia", + image="linode/ubuntu24.04", + + # This can be configured as an account-wide default + interface_generation=InterfaceGeneration.LINODE, + + interfaces=[ + LinodeInterfaceOptions( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions + ) + ] + ) + **Create an empty Instance** If you want to create an empty Instance that you will configure manually, @@ -294,9 +328,13 @@ def instance_create( :type disk_encryption: InstanceDiskEncryptionType or str :param interfaces: An array of Network Interfaces to add to this Linode’s Configuration Profile. At least one and up to three Interface objects can exist in this array. - :type interfaces: list[ConfigInterface] or list[dict[str, Any]] + :type interfaces: List[LinodeInterfaceOptions], List[NetworkInterface], 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 interface_generation: The generation of network interfaces this Linode uses. + :type interface_generation: InterfaceGeneration or str + :param network_helper: Whether this instance should have Network Helper enabled. + :type network_helper: bool :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. If not provided, the default policy (linode/migrate) will be applied. NOTE: This field is in beta and may only @@ -317,13 +355,6 @@ def instance_create( ret_pass = Instance.generate_root_password() kwargs["root_pass"] = ret_pass - interfaces = kwargs.get("interfaces", None) - if interfaces is not None and isinstance(interfaces, Iterable): - kwargs["interfaces"] = [ - i._serialize() if isinstance(i, ConfigInterface) else i - for i in interfaces - ] - params = { "type": ltype, "region": region, @@ -343,6 +374,9 @@ def instance_create( if placement_group else None ), + "interfaces": interfaces, + "interface_generation": interface_generation, + "network_helper": network_helper, } params.update(kwargs) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index ba1e656bd..b16d12d9a 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -1,9 +1,14 @@ +from typing import Any, Dict, Optional, Union + from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( VLAN, Base, Firewall, + FirewallCreateDevicesOptions, + FirewallSettings, + FirewallTemplate, Instance, IPAddress, IPv6Pool, @@ -11,6 +16,8 @@ NetworkTransferPrice, Region, ) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys class NetworkingGroup(Group): @@ -33,7 +40,15 @@ def firewalls(self, *filters): """ return self.client._get_and_filter(Firewall, *filters) - def firewall_create(self, label, rules, **kwargs): + def firewall_create( + self, + label: str, + rules: Dict[str, Any], + devices: Optional[ + Union[FirewallCreateDevicesOptions, Dict[str, Any]] + ] = None, + **kwargs, + ): """ Creates a new Firewall, either in the given Region or attached to the given Instance. @@ -44,6 +59,8 @@ def firewall_create(self, label, rules, **kwargs): :type label: str :param rules: The rules to apply to the new Firewall. For more information on Firewall rules, see our `Firewalls Documentation`_. :type rules: dict + :param devices: Represents devices to create created alongside a Linode Firewall. + :type devices: Optional[Union[FirewallCreateDevicesOptions, Dict[str, Any]]] :returns: The new Firewall. :rtype: Firewall @@ -81,10 +98,14 @@ def firewall_create(self, label, rules, **kwargs): params = { "label": label, "rules": rules, + "devices": devices, } params.update(kwargs) - result = self.client.post("/networking/firewalls", data=params) + result = self.client.post( + "/networking/firewalls", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) if not "id" in result: raise UnexpectedResponseError( @@ -94,6 +115,43 @@ def firewall_create(self, label, rules, **kwargs): f = Firewall(self.client, result["id"], result) return f + def firewall_templates(self, *filters): + """ + Returns a list of Firewall Templates available to the current user. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-templates + + NOTE: This feature may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Firewall Templates available to the current user. + :rtype: PaginatedList of FirewallTemplate + """ + return self.client._get_and_filter(FirewallTemplate, *filters) + + def firewall_settings(self) -> FirewallSettings: + """ + Returns an object representing the Linode Firewall settings for the current user. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings + + NOTE: This feature may not currently be available to all users. + :returns: An object representing the Linode Firewall settings for the current user. + :rtype: FirewallSettings + """ + result = self.client.get("/networking/firewalls/settings") + + if "default_firewall_ids" not in result: + raise UnexpectedResponseError( + "Unexpected response when getting firewall settings!", + json=result, + ) + + return FirewallSettings(self.client, None, result) + def ips(self, *filters): """ Returns a list of IP addresses on this account, excluding private addresses. @@ -124,6 +182,64 @@ def ipv6_ranges(self, *filters): """ return self.client._get_and_filter(IPv6Range, *filters) + def ipv6_range_allocate( + self, + prefix_length: int, + route_target: Optional[str] = None, + linode: Optional[Union[Instance, int]] = None, + **kwargs, + ) -> IPv6Range: + """ + Creates an IPv6 Range and assigns it based on the provided Linode or route target IPv6 SLAAC address. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-ipv6-range + + Create an IPv6 range assigned to a Linode by ID:: + + range = client.networking.ipv6_range_allocate(64, linode_id=123) + + + Create an IPv6 range assigned to a Linode by SLAAC:: + + range = client.networking.ipv6_range_allocate( + 64, + route_target=instance.ipv6.split("/")[0] + ) + + :param prefix_length: The prefix length of the IPv6 range. + :type prefix_length: int + :param route_target: The IPv6 SLAAC address to assign this range to. Required if linode is not specified. + :type route_target: str + :param linode: The ID of the Linode to assign this range to. + The SLAAC address for the provided Linode is used as the range's route_target. + Required if linode is not specified. + :type linode: Instance or int + + :returns: The new IPAddress. + :rtype: IPAddress + """ + + params = { + "prefix_length": prefix_length, + "route_target": route_target, + "linode_id": linode, + } + + params.update(**kwargs) + + result = self.client.post( + "/networking/ipv6/ranges", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if not "range" in result: + raise UnexpectedResponseError( + "Unexpected response when allocating IPv6 range!", json=result + ) + + result = IPv6Range(self.client, result["range"], result) + return result + def ipv6_pools(self, *filters): """ Returns a list of IPv6 pools on this account. diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index c847024d8..9f120310c 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -6,6 +6,7 @@ from .region import Region from .image import Image from .linode import * +from .linode_interfaces import * from .volume import * from .domain import * from .account import * diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 5cf564bff..54298ed11 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -17,6 +17,7 @@ from linode_api4.objects.networking import Firewall from linode_api4.objects.nodebalancer import NodeBalancer from linode_api4.objects.profile import PersonalAccessToken +from linode_api4.objects.serializable import StrEnum from linode_api4.objects.support import SupportTicket from linode_api4.objects.volume import Volume from linode_api4.objects.vpc import VPC @@ -180,6 +181,24 @@ class Login(Base): } +class AccountSettingsInterfacesForNewLinodes(StrEnum): + """ + A string enum corresponding to valid values + for the AccountSettings(...).interfaces_for_new_linodes field. + + NOTE: This feature may not currently be available to all users. + """ + + legacy_config_only = "legacy_config_only" + legacy_config_default_but_linode_allowed = ( + "legacy_config_default_but_linode_allowed" + ) + linode_default_but_legacy_config_allowed = ( + "linode_default_but_legacy_config_allowed" + ) + linode_only = "linode_only" + + class AccountSettings(Base): """ Information related to your Account settings. @@ -198,6 +217,7 @@ class AccountSettings(Base): ), "object_storage": Property(), "backups_enabled": Property(mutable=True), + "interfaces_for_new_linodes": Property(mutable=True), "maintenance_policy": Property( mutable=True ), # Note: This field is only available when using v4beta. diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index c9a622edc..51a16eae0 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -239,6 +239,7 @@ def __setattr__(self, name, value): """ Enforces allowing editing of only Properties defined as mutable """ + if name in type(self).properties.keys(): if not type(self).properties[name].mutable: raise AttributeError( diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 2d051fb44..fd4f990de 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,7 @@ +import copy import string import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from os import urandom @@ -19,6 +20,14 @@ from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.filtering import FilterableAttribute from linode_api4.objects.image import Image +from linode_api4.objects.linode_interfaces import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicOptions, + LinodeInterfacesSettings, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, +) from linode_api4.objects.networking import ( Firewall, IPAddress, @@ -653,6 +662,33 @@ class MigrationType: WARM = "warm" +class InterfaceGeneration(StrEnum): + """ + A string enum representing which interface generation a Linode is using. + """ + + LEGACY_CONFIG = "legacy_config" + LINODE = "linode" + + +@dataclass +class UpgradeInterfacesResult(JSONObject): + """ + Contains information about an Linode Interface upgrade operation. + + NOTE: If dry_run is True, each returned interface will be of type Dict[str, Any]. + Otherwise, each returned interface will be of type LinodeInterface. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + """ + + dry_run: bool = False + config_id: int = 0 + interfaces: List[Union[Dict[str, Any], LinodeInterface]] = field( + default_factory=list + ) + + class Instance(Base): """ A Linode Instance. @@ -686,6 +722,7 @@ class Instance(Base): "disk_encryption": Property(), "lke_cluster_id": Property(), "capabilities": Property(unordered=True), + "interface_generation": Property(), "maintenance_policy": Property( mutable=True ), # Note: This field is only available when using v4beta. @@ -699,8 +736,8 @@ def ips(self): API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-ips - :returns: A List of the ips of the Linode Instance. - :rtype: List[IPAddress] + :returns: Information about the IP addresses assigned to this instance. + :rtype: MappedObject """ if not hasattr(self, "_ips"): result = self._client.get( @@ -965,6 +1002,9 @@ def invalidate(self): if hasattr(self, "_placement_group"): del self._placement_group + if hasattr(self, "_interfaces"): + del self._interfaces + Base.invalidate(self) def boot(self, config=None): @@ -1849,6 +1889,213 @@ def stats_for(self, dt): model=self, ) + def interface_create( + self, + firewall: Optional[Union[Firewall, int]] = None, + default_route: Optional[ + Union[Dict[str, Any], LinodeInterfaceDefaultRouteOptions] + ] = None, + public: Optional[ + Union[Dict[str, Any], LinodeInterfacePublicOptions] + ] = None, + vlan: Optional[ + Union[Dict[str, Any], LinodeInterfaceVLANOptions] + ] = None, + vpc: Optional[Union[Dict[str, Any], LinodeInterfaceVPCOptions]] = None, + **kwargs, + ) -> LinodeInterface: + """ + Creates a new interface under this Linode. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-interface + + Example: Creating a simple public interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True + ), + public=LinodeInterfacePublicOptions() + ) + + Example: Creating a simple VPC interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=12345 + ) + ) + + Example: Creating a simple VLAN interface for this Linode:: + + interface = instance.interface_create( + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True + ), + vlan=LinodeInterfaceVLANOptions( + vlan_label="my-vlan" + ) + ) + + :param firewall: The firewall this interface should be assigned to. + :param default_route: The desired default route configuration of the new interface. + :param public: The public-specific configuration of the new interface. + If set, the new instance will be a public interface. + :param vlan: The VLAN-specific configuration of the new interface. + If set, the new instance will be a VLAN interface. + :param vpc: The VPC-specific configuration of the new interface. + If set, the new instance will be a VPC interface. + + :returns: The newly created Linode Interface. + :rtype: LinodeInterface + """ + + params = { + "firewall_id": firewall, + "default_route": default_route, + "public": public, + "vlan": vlan, + "vpc": vpc, + } + + params.update(kwargs) + + result = self._client.post( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response creating interface!", json=result + ) + + return LinodeInterface(self._client, result["id"], self.id, json=result) + + @property + def interfaces_settings(self) -> LinodeInterfacesSettings: + """ + The settings for all interfaces under this Linode. + + NOTE: Linode interfaces may not currently be available to all users. + + :returns: The settings for instance-level interface settings for this Linode. + :rtype: LinodeInterfacesSettings + """ + + # NOTE: We do not implement this as a Property because Property does + # not currently have a mechanism for 1:1 sub-entities. + + if not hasattr(self, "_interfaces_settings"): + self._set( + "_interfaces_settings", + # We don't use lazy loading here because it can trigger a known issue + # where setting fields for updates before the entity has been lazy loaded + # causes the user's value to be discarded. + self._client.load(LinodeInterfacesSettings, self.id), + ) + + return self._interfaces_settings + + @property + def interfaces(self) -> List[LinodeInterface]: + """ + All interfaces for this Linode. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + + :returns: An ordered list of interfaces under this Linode. + """ + + if not hasattr(self, "_interfaces"): + result = self._client.get( + "{}/interfaces".format(Instance.api_endpoint), + model=self, + ) + if "interfaces" not in result: + raise UnexpectedResponseError( + "Got unexpected response when retrieving Linode interfaces", + json=result, + ) + + self._set( + "_interfaces", + [ + LinodeInterface( + self._client, iface["id"], self.id, json=iface + ) + for iface in result["interfaces"] + ], + ) + + return self._interfaces + + def upgrade_interfaces( + self, + config: Optional[Union[Config, int]] = None, + dry_run: bool = False, + **kwargs, + ) -> UpgradeInterfacesResult: + """ + Automatically upgrades all legacy config interfaces of a + single configuration profile to Linode interfaces. + + NOTE: If dry_run is True, interfaces in the result will be + of type MappedObject rather than LinodeInterface. + + NOTE: Linode interfaces may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-upgrade-linode-interfaces + + :param config: The configuration profile the legacy interfaces to + upgrade are under. + :type config: Config or int + :param dry_run: Whether this operation should be a dry run, + which will return the interfaces that would be + created if the operation were completed. + :type dry_run: bool + + :returns: Information about the newly upgraded interfaces. + :rtype: UpgradeInterfacesResult + """ + params = {"config_id": config, "dry_run": dry_run} + + params.update(kwargs) + + result = self._client.post( + "{}/upgrade-interfaces".format(Instance.api_endpoint), + model=self, + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + # This resolves an edge case where `result["interfaces"]` persists across + # multiple calls, which can cause parsing errors when expanding them below. + result = copy.deepcopy(result) + + self.invalidate() + + # We don't convert interface dicts to LinodeInterface objects on dry runs + # actual API entities aren't created. + if dry_run: + result["interfaces"] = [ + MappedObject(**iface) for iface in result["interfaces"] + ] + else: + result["interfaces"] = [ + LinodeInterface(self._client, iface["id"], self.id, iface) + for iface in result["interfaces"] + ] + + return UpgradeInterfacesResult.from_json(result) + class UserDefinedFieldType(Enum): text = 1 diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py new file mode 100644 index 000000000..391cb8650 --- /dev/null +++ b/linode_api4/objects/linode_interfaces.py @@ -0,0 +1,477 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Union + +from linode_api4.objects.base import Base, ExplicitNullValue, Property +from linode_api4.objects.dbase import DerivedBase +from linode_api4.objects.networking import Firewall +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): + """ + The options used to configure the default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4_interface_id: Optional[int] = None + ipv6_interface_id: Optional[int] = None + + +@dataclass +class LinodeInterfacesSettingsDefaultRoute(JSONObject): + """ + The default route settings for a Linode's network interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacesSettingsDefaultRouteOptions + + ipv4_interface_id: Optional[int] = None + ipv4_eligible_interface_ids: List[int] = field(default_factory=list) + ipv6_interface_id: Optional[int] = None + ipv6_eligible_interface_ids: List[int] = field(default_factory=list) + + +class LinodeInterfacesSettings(Base): + """ + The settings related to a Linode's network interfaces. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-settings + + NOTE: Linode interfaces may not currently be available to all users. + """ + + api_endpoint = "/linode/instances/{id}/interfaces/settings" + + properties = { + "id": Property(identifier=True), + "network_helper": Property(mutable=True), + "default_route": Property( + mutable=True, json_object=LinodeInterfacesSettingsDefaultRoute + ), + } + + +# Interface POST Options +@dataclass +class LinodeInterfaceDefaultRouteOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface's default route settings. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4: Optional[bool] = None + ipv6: Optional[bool] = None + + +@dataclass +class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: Optional[str] = None + primary: Optional[bool] = None + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + addresses: Optional[List[LinodeInterfaceVPCIPv4AddressOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None + + +@dataclass +class LinodeInterfaceVPCOptions(JSONObject): + """ + VPC-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + subnet_id: int = 0 + ipv4: Optional[LinodeInterfaceVPCIPv4Options] = None + + +@dataclass +class LinodeInterfacePublicIPv4AddressOptions(JSONObject): + """ + Options accepted for a single address when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + primary: Optional[bool] = None + + +@dataclass +class LinodeInterfacePublicIPv4Options(JSONObject): + """ + Options accepted when creating or updating the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + addresses: Optional[List[LinodeInterfacePublicIPv4AddressOptions]] = None + + +@dataclass +class LinodeInterfacePublicIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfacePublicIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ranges: Optional[List[LinodeInterfacePublicIPv6RangeOptions]] = None + + +@dataclass +class LinodeInterfacePublicOptions(JSONObject): + """ + Public-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + ipv4: Optional[LinodeInterfacePublicIPv4Options] = None + ipv6: Optional[LinodeInterfacePublicIPv6Options] = None + + +@dataclass +class LinodeInterfaceVLANOptions(JSONObject): + """ + VLAN-exclusive options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceOptions(JSONObject): + """ + Options accepted when creating or updating a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + # If a default firewall_id isn't configured, the API requires that + # firewall_id is defined in the LinodeInterface POST body. + # + # To create a Linode Interface without a firewall, this field should + # be set to `ExplicitNullValue()`. + firewall_id: Union[int, ExplicitNullValue, None] = None + + default_route: Optional[LinodeInterfaceDefaultRouteOptions] = None + vpc: Optional[LinodeInterfaceVPCOptions] = None + public: Optional[LinodeInterfacePublicOptions] = None + vlan: Optional[LinodeInterfaceVLANOptions] = None + + +# Interface GET Response + + +@dataclass +class LinodeInterfaceDefaultRoute(JSONObject): + """ + The default route configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceDefaultRouteOptions + + ipv4: bool = False + ipv6: bool = False + + +@dataclass +class LinodeInterfaceVPCIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4AddressOptions + + address: str = "" + primary: bool = False + nat_1_1_address: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv4Range(JSONObject): + """ + A single range under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4RangeOptions + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv4(JSONObject): + """ + A single address under the IPv4 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv4Options + + addresses: List[LinodeInterfaceVPCIPv4Address] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfaceVPC(JSONObject): + """ + VPC-specific configuration field for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCOptions + + vpc_id: int = 0 + subnet_id: int = 0 + + ipv4: Optional[LinodeInterfaceVPCIPv4] = None + + +@dataclass +class LinodeInterfacePublicIPv4Address(JSONObject): + """ + A single address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv4AddressOptions + + address: str = "" + primary: bool = False + + +@dataclass +class LinodeInterfacePublicIPv4Shared(JSONObject): + """ + A single shared address under the IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + linode_id: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv4(JSONObject): + """ + The IPv4 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv4Options + + addresses: List[LinodeInterfacePublicIPv4Address] = field( + default_factory=list + ) + shared: List[LinodeInterfacePublicIPv4Shared] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublicIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + address: str = "" + prefix: int = 0 + + +@dataclass +class LinodeInterfacePublicIPv6Shared(JSONObject): + """ + A single shared range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a public Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv6RangeOptions + + range: str = "" + route_target: Optional[str] = None + + +@dataclass +class LinodeInterfacePublicIPv6(JSONObject): + """ + The IPv6 configuration of a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicIPv6Options + + slaac: List[LinodeInterfacePublicIPv6SLAAC] = field(default_factory=list) + shared: List[LinodeInterfacePublicIPv6Shared] = field(default_factory=list) + ranges: List[LinodeInterfacePublicIPv6Range] = field(default_factory=list) + + +@dataclass +class LinodeInterfacePublic(JSONObject): + """ + Public-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfacePublicOptions + + ipv4: Optional[LinodeInterfacePublicIPv4] = None + ipv6: Optional[LinodeInterfacePublicIPv6] = None + + +@dataclass +class LinodeInterfaceVLAN(JSONObject): + """ + VLAN-specific configuration fields for a Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVLANOptions + + vlan_label: str = "" + ipam_address: Optional[str] = None + + +class LinodeInterface(DerivedBase): + """ + A Linode's network interface. + + NOTE: Linode interfaces may not currently be available to all users. + + NOTE: When using the ``save()`` method, certain local fields with computed values will + not be refreshed on the local object until after ``invalidate()`` has been called:: + + # Automatically assign an IPv4 address from the associated VPC Subnet + interface.vpc.ipv4.addresses[0].address = "auto" + + # Save the interface + interface.save() + + # Invalidate the interface + interface.invalidate() + + # Access the new address + print(interface.vpc.ipv4.addresses[0].address) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface + """ + + api_endpoint = "/linode/instances/{linode_id}/interfaces/{id}" + derived_url_path = "interfaces" + parent_id_name = "linode_id" + + properties = { + "linode_id": Property(identifier=True), + "id": Property(identifier=True), + "mac_address": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "version": Property(), + "default_route": Property( + mutable=True, + json_object=LinodeInterfaceDefaultRoute, + ), + "public": Property(mutable=True, json_object=LinodeInterfacePublic), + "vlan": Property(mutable=True, json_object=LinodeInterfaceVLAN), + "vpc": Property(mutable=True, json_object=LinodeInterfaceVPC), + } + + def firewalls(self, *filters) -> List[Firewall]: + """ + Retrieves a list of Firewalls for this Linode Interface. + Linode interfaces are not interchangeable with Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A List of Firewalls for this Linode Interface. + :rtype: List[Firewall] + + NOTE: Caching is disabled on this method and each call will make + an additional Linode API request. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface-firewalls + """ + + return self._client._get_and_filter( + Firewall, + *filters, + endpoint="{}/firewalls".format(LinodeInterface.api_endpoint).format( + **vars(self) + ), + ) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index b7a16ae90..1219380fc 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass -from typing import Optional +from dataclasses import dataclass, field +from typing import List, Optional from linode_api4.common import Price, RegionPrice from linode_api4.errors import UnexpectedResponseError @@ -87,6 +87,7 @@ class IPAddress(Base): "public": Property(), "rdns": Property(mutable=True), "linode_id": Property(), + "interface_id": Property(), "region": Property(slug_relationship=Region), "vpc_nat_1_1": Property(json_object=InstanceIPNAT1To1), } @@ -97,8 +98,36 @@ def linode(self): if not hasattr(self, "_linode"): self._set("_linode", Instance(self._client, self.linode_id)) + return self._linode + @property + def interface(self) -> Optional["LinodeInterface"]: + """ + Returns the Linode Interface associated with this IP address. + + NOTE: This function will only return Linode interfaces, not Config interfaces. + + NOTE: Linode interfaces may not currently be available to all users. + + :returns: The Linode Interface associated with this IP address. + :rtype: LinodeInterface + """ + + from .linode_interfaces import LinodeInterface # pylint: disable-all + + if self.interface_id in (None, 0): + self._set("_interface", None) + elif not hasattr(self, "_interface"): + self._set( + "_interface", + LinodeInterface( + self._client, self.linode_id, self.interface_id + ), + ) + + return self._interface + def to(self, linode): """ This is a helper method for ip-assign, and should not be used outside @@ -176,6 +205,53 @@ class VLAN(Base): } +@dataclass +class FirewallCreateDevicesOptions(JSONObject): + """ + Represents devices to create created alongside a Linode Firewall. + """ + + linodes: List[int] = field(default_factory=list) + nodebalancers: List[int] = field(default_factory=list) + interfaces: List[int] = field(default_factory=list) + + +@dataclass +class FirewallSettingsDefaultFirewallIDs(JSONObject): + """ + Contains the IDs of Linode Firewalls that should be used by default + when creating various interface types. + + NOTE: This feature may not currently be available to all users. + """ + + include_none_values = True + + vpc_interface: Optional[int] = None + public_interface: Optional[int] = None + linode: Optional[int] = None + nodebalancer: Optional[int] = None + + +class FirewallSettings(Base): + """ + Represents the Firewall settings for the current user. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-settings + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/firewalls/settings" + + properties = { + "default_firewall_ids": Property( + json_object=FirewallSettingsDefaultFirewallIDs, + mutable=True, + ), + } + + class FirewallDevice(DerivedBase): """ An object representing the assignment between a Linode Firewall and another Linode resource. @@ -307,6 +383,22 @@ def device_create(self, id, type="linode", **kwargs): return c +class FirewallTemplate(Base): + """ + Represents a single Linode Firewall template. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-firewall-template + + NOTE: This feature may not currently be available to all users. + """ + + api_endpoint = "/networking/firewalls/templates/{slug}" + + id_attribute = "slug" + + properties = {"slug": Property(identifier=True), "rules": Property()} + + class NetworkTransferPrice(Base): """ An NetworkTransferPrice represents the structure of a valid network transfer price. diff --git a/linode_api4/objects/serializable.py b/linode_api4/objects/serializable.py index 1660795aa..c1f59f6d4 100644 --- a/linode_api4/objects/serializable.py +++ b/linode_api4/objects/serializable.py @@ -186,6 +186,16 @@ def attempt_serialize(value: Any) -> Any: if issubclass(type(value), JSONObject): return value._serialize(is_put=is_put) + # Needed to avoid circular imports without a breaking change + from linode_api4.objects.base import ( # pylint: disable=import-outside-toplevel + ExplicitNullValue, + ) + + if value == ExplicitNullValue or isinstance( + value, ExplicitNullValue + ): + return None + return value def should_include(key: str, value: Any) -> bool: diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 3c9a4aaba..94c0302f0 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -11,6 +11,7 @@ @dataclass class VPCSubnetLinodeInterface(JSONObject): id: int = 0 + config_id: Optional[int] = None active: bool = False diff --git a/test/fixtures/account.json b/test/fixtures/account.json index 1d823798b..001d7adad 100644 --- a/test/fixtures/account.json +++ b/test/fixtures/account.json @@ -16,7 +16,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "active_promotions": [ { diff --git a/test/fixtures/account_settings.json b/test/fixtures/account_settings.json index dda69f1ab..963c37306 100644 --- a/test/fixtures/account_settings.json +++ b/test/fixtures/account_settings.json @@ -4,5 +4,6 @@ "network_helper": false, "object_storage": "active", "backups_enabled": true, + "interfaces_for_new_linodes": "linode_default_but_legacy_config_allowed", "maintenance_policy": "linode/migrate" } diff --git a/test/fixtures/linode_instances.json b/test/fixtures/linode_instances.json index 452fc354d..08cbe80c8 100644 --- a/test/fixtures/linode_instances.json +++ b/test/fixtures/linode_instances.json @@ -38,7 +38,9 @@ ], "updated": "2017-01-01T00:00:00", "image": "linode/ubuntu17.04", - "tags": ["something"], + "tags": [ + "something" + ], "host_uuid": "3a3ddd59d9a78bb8de041391075df44de62bfec8", "watchdog_enabled": true, "disk_encryption": "disabled", @@ -91,7 +93,52 @@ "watchdog_enabled": false, "disk_encryption": "enabled", "lke_cluster_id": 18881, + "placement_group": null + }, + { + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": [ + "something" + ], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, "placement_group": null, + "interface_generation": "linode", "maintenance_policy" : "linode/power_off_on" } ] diff --git a/test/fixtures/linode_instances_124.json b/test/fixtures/linode_instances_124.json new file mode 100644 index 000000000..6c059ba41 --- /dev/null +++ b/test/fixtures/linode_instances_124.json @@ -0,0 +1,43 @@ +{ + "id": 124, + "status": "running", + "type": "g6-standard-1", + "alerts": { + "network_in": 5, + "network_out": 5, + "cpu": 90, + "transfer_quota": 80, + "io": 5000 + }, + "group": "test", + "hypervisor": "kvm", + "label": "linode124", + "backups": { + "enabled": true, + "schedule": { + "window": "W02", + "day": "Scheduling" + } + }, + "specs": { + "memory": 2048, + "disk": 30720, + "vcpus": 1, + "transfer": 2000 + }, + "ipv6": "1235:abcd::1234:abcd:89ef:67cd/64", + "created": "2017-01-01T00:00:00", + "region": "us-east-1", + "ipv4": [ + "124.45.67.89" + ], + "updated": "2017-01-01T00:00:00", + "image": "linode/ubuntu24.04", + "tags": ["something"], + "host_uuid": "3b3ddd59d9a78bb8de041391075df44de62bfec8", + "watchdog_enabled": true, + "disk_encryption": "disabled", + "lke_cluster_id": null, + "placement_group": null, + "interface_generation": "linode" +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json new file mode 100644 index 000000000..890e5c84d --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -0,0 +1,103 @@ +{ + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" + }, + { + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3c09:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3c09::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123.json b/test/fixtures/linode_instances_124_interfaces_123.json new file mode 100644 index 000000000..2dc912812 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123.json @@ -0,0 +1,53 @@ +{ + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" + }, + { + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3c09:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3c09::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces_123_firewalls.json b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json new file mode 100644 index 000000000..17a4a9199 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_123_firewalls.json @@ -0,0 +1,56 @@ +{ + "data": [ + { + "created": "2018-01-01T00:01:01", + "id": 123, + "label": "firewall123", + "rules": { + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP", + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24" + ], + "ipv6": [ + "2001:DB8::/32" + ] + }, + "description": "An example firewall rule description.", + "label": "firewallrule123", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP" + }, + "status": "enabled", + "tags": [ + "example tag", + "another example" + ], + "updated": "2018-01-02T00:01:01" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json new file mode 100644 index 000000000..7fc4f56f8 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -0,0 +1,28 @@ +{ + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4":true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4" : { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { "range": "192.168.22.16/28"}, + { "range": "192.168.22.32/28"} + ] + } + }, + "public": null, + "vlan": null +} diff --git a/test/fixtures/linode_instances_124_interfaces_789.json b/test/fixtures/linode_instances_124_interfaces_789.json new file mode 100644 index 000000000..d533b8e21 --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_789.json @@ -0,0 +1,14 @@ +{ + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } +} diff --git a/test/fixtures/linode_instances_124_interfaces_settings.json b/test/fixtures/linode_instances_124_interfaces_settings.json new file mode 100644 index 000000000..b454c438e --- /dev/null +++ b/test/fixtures/linode_instances_124_interfaces_settings.json @@ -0,0 +1,16 @@ +{ + "network_helper": true, + "default_route": { + "ipv4_interface_id": 123, + "ipv4_eligible_interface_ids": [ + 123, + 456, + 789 + ], + "ipv6_interface_id": 456, + "ipv6_eligible_interface_ids": [ + 123, + 456 + ] + } +} \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json new file mode 100644 index 000000000..ad1b3d035 --- /dev/null +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -0,0 +1,105 @@ +{ + "dry_run": true, + "config_id": 123, + "interfaces": [ + { + "created": "2025-01-01T00:01:01", + "default_route": { + "ipv4": true, + "ipv6": true + }, + "id": 123, + "mac_address": "22:00:AB:CD:EF:01", + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.50", + "primary": true + } + ], + "shared": [ + { + "address": "172.30.0.51", + "linode_id": 125 + } + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:59::/64", + "route_target": "2600:3c09::ff:feab:cdef" + }, + { + "range": "2600:3c09:e001:5a::/64", + "route_target": "2600:3c09::ff:feab:cdef" + } + ], + "shared": [ + { + "range": "2600:3c09:e001:2a::/64", + "route_target": null + } + ], + "slaac": [ + { + "address": "2600:3c09::ff:feab:cdef", + "prefix": 64 + } + ] + } + }, + "updated": "2025-01-01T00:01:01", + "version": 1, + "vlan": null, + "vpc": null + }, + { + "id": 456, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": { + "ipv4": true + }, + "version": 1, + "vpc": { + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.3", + "primary": true + } + ], + "ranges": [ + { + "range": "192.168.22.16/28" + }, + { + "range": "192.168.22.32/28" + } + ] + } + }, + "public": null, + "vlan": null + }, + { + "id": 789, + "mac_address": "22:00:AB:CD:EF:01", + "created": "2024-01-01T00:01:01", + "updated": "2024-01-01T00:01:01", + "default_route": {}, + "version": 1, + "vpc": null, + "public": null, + "vlan": { + "vlan_label": "my_vlan", + "ipam_address": "10.0.0.1/24" + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices.json b/test/fixtures/networking_firewalls_123_devices.json index ae4efe2d0..e43e3725a 100644 --- a/test/fixtures/networking_firewalls_123_devices.json +++ b/test/fixtures/networking_firewalls_123_devices.json @@ -10,9 +10,20 @@ }, "id": 123, "updated": "2018-01-02T00:01:01" + }, + { + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": null, + "type": "interface", + "url": "/v4/linode/instances/123/interfaces/123" + }, + "id": 456, + "updated": "2018-01-02T00:01:01" } ], "page": 1, "pages": 1, - "results": 1 + "results": 2 } \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_devices_456.json b/test/fixtures/networking_firewalls_123_devices_456.json new file mode 100644 index 000000000..aa76901ee --- /dev/null +++ b/test/fixtures/networking_firewalls_123_devices_456.json @@ -0,0 +1,11 @@ +{ + "created": "2018-01-01T00:01:01", + "entity": { + "id": 123, + "label": null, + "type": "interface", + "url": "/v4/linode/instances/123/interfaces/123" + }, + "id": 456, + "updated": "2018-01-02T00:01:01" +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_settings.json b/test/fixtures/networking_firewalls_settings.json new file mode 100644 index 000000000..bfb7b2853 --- /dev/null +++ b/test/fixtures/networking_firewalls_settings.json @@ -0,0 +1,8 @@ +{ + "default_firewall_ids": { + "vpc_interface": 123, + "public_interface": 456, + "linode": 789, + "nodebalancer": 321 + } +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates.json b/test/fixtures/networking_firewalls_templates.json new file mode 100644 index 000000000..b0267c7b4 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates.json @@ -0,0 +1,93 @@ +{ + "data": [ + { + "slug": "public", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } + }, + { + "slug": "vpc", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates_public.json b/test/fixtures/networking_firewalls_templates_public.json new file mode 100644 index 000000000..6b33e9f73 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates_public.json @@ -0,0 +1,43 @@ +{ + "slug": "public", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } +} \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_templates_vpc.json b/test/fixtures/networking_firewalls_templates_vpc.json new file mode 100644 index 000000000..839bd6824 --- /dev/null +++ b/test/fixtures/networking_firewalls_templates_vpc.json @@ -0,0 +1,43 @@ +{ + "slug": "vpc", + "rules": { + "outbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "outbound_policy": "DROP", + "inbound": [ + { + "action": "ACCEPT", + "addresses": { + "ipv4": [ + "192.0.2.0/24", + "198.51.100.2/32" + ], + "ipv6": [ + "2001:DB8::/128" + ] + }, + "description": "test", + "label": "test-rule", + "ports": "22-24, 80, 443", + "protocol": "TCP" + } + ], + "inbound_policy": "DROP" + } +} \ No newline at end of file diff --git a/test/fixtures/networking_ips_127.0.0.1.json b/test/fixtures/networking_ips_127.0.0.1.json index 9d3cfb449..7abb0fabd 100644 --- a/test/fixtures/networking_ips_127.0.0.1.json +++ b/test/fixtures/networking_ips_127.0.0.1.json @@ -2,6 +2,7 @@ "address": "127.0.0.1", "gateway": "127.0.0.1", "linode_id": 123, + "interface_id": 456, "prefix": 24, "public": true, "rdns": "test.example.org", diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index 5fe55e200..b58db045d 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -6,7 +6,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -26,7 +27,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -46,7 +48,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -62,7 +65,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -82,7 +86,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -102,7 +107,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -123,7 +129,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -143,7 +150,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -164,7 +172,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -185,7 +194,8 @@ "Linodes", "NodeBalancers", "Block Storage", - "Object Storage" + "Object Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { @@ -205,7 +215,8 @@ "capabilities": [ "Linodes", "NodeBalancers", - "Block Storage" + "Block Storage", + "Linode Interfaces" ], "status": "ok", "resolvers": { diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index f846399df..37537efb2 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -10,11 +10,13 @@ "interfaces": [ { "id": 678, - "active": true + "active": true, + "config_id": null }, { "id": 543, - "active": false + "active": false, + "config_id": null } ] } diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index ba6973472..7fac495c4 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -8,11 +8,13 @@ "interfaces": [ { "id": 678, - "active": true + "active": true, + "config_id": null }, { "id": 543, - "active": false + "active": false, + "config_id": null } ] } diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 75f0c8f5d..9d2ec0eca 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -15,6 +15,13 @@ from requests.exceptions import ConnectionError, RequestException from linode_api4 import ( + ExplicitNullValue, + InterfaceGeneration, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCOptions, PlacementGroupPolicy, PlacementGroupType, PostgreSQLDatabase, @@ -529,6 +536,77 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_with_interface_generation_linode( + test_linode_client, + e2e_test_firewall, + # We won't be using this all the time, but it's + # necessary for certain consumers of this fixture + create_vpc_with_subnet, +): + client = test_linode_client + + label = get_test_label() + + instance = client.linode.instance_create( + "g6-nanode-1", + create_vpc_with_subnet[0].region, + label=label, + interface_generation=InterfaceGeneration.LINODE, + booted=False, + ) + + yield instance + + instance.delete() + + +@pytest.fixture(scope="function") +def linode_with_linode_interfaces( + test_linode_client, e2e_test_firewall, create_vpc_with_subnet +): + client = test_linode_client + vpc, subnet = create_vpc_with_subnet + + # Are there regions where VPCs are supported but Linode Interfaces aren't? + region = vpc.region + label = get_test_label() + + instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + booted=False, + interface_generation=InterfaceGeneration.LINODE, + interfaces=[ + LinodeInterfaceOptions( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions(), + ), + LinodeInterfaceOptions( + firewall_id=ExplicitNullValue, + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ), + ), + LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), + ], + ) + + yield instance + + instance.delete() + + @pytest.fixture(scope="session") def test_create_postgres_db(test_linode_client): client = test_linode_client diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index eb1b06369..da7e93cef 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -6,7 +6,11 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region +from linode_api4.objects import ( + ConfigInterface, + ObjectStorageKeys, + Region, +) @pytest.fixture(scope="session") diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 805f713b6..5833a9344 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -59,6 +59,7 @@ def test_get_account_settings(test_linode_client): assert "longview_subscription" in str(account_settings._raw_json) assert "backups_enabled" in str(account_settings._raw_json) assert "object_storage" in str(account_settings._raw_json) + assert isinstance(account_settings.interfaces_for_new_linodes, str) assert "maintenance_policy" in str(account_settings._raw_json) diff --git a/test/integration/models/firewall/test_firewall_templates.py b/test/integration/models/firewall/test_firewall_templates.py new file mode 100644 index 000000000..11d6ccb6f --- /dev/null +++ b/test/integration/models/firewall/test_firewall_templates.py @@ -0,0 +1,33 @@ +from linode_api4 import FirewallTemplate, MappedObject + + +def __assert_firewall_template_rules(rules: MappedObject): + # We can't confidently say that these rules will not be changed + # in the future, so we can just do basic assertions here. + assert isinstance(rules.inbound_policy, str) + assert len(rules.inbound_policy) > 0 + + assert isinstance(rules.outbound_policy, str) + assert len(rules.outbound_policy) > 0 + + assert isinstance(rules.outbound, list) + assert isinstance(rules.inbound, list) + + +def test_list_firewall_templates(test_linode_client): + templates = test_linode_client.networking.firewall_templates() + assert len(templates) > 0 + + for template in templates: + assert isinstance(template.slug, str) + assert len(template.slug) > 0 + + __assert_firewall_template_rules(template.rules) + + +def test_get_firewall_template(test_linode_client): + template = test_linode_client.load(FirewallTemplate, "vpc") + + assert template.slug == "vpc" + + __assert_firewall_template_rules(template.rules) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py new file mode 100644 index 000000000..07dffd66a --- /dev/null +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -0,0 +1,343 @@ +import copy +import ipaddress + +import pytest + +from linode_api4 import ( + ApiError, + Instance, + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def test_linode_create_with_linode_interfaces( + create_vpc_with_subnet, + linode_with_linode_interfaces, +): + instance: Instance = linode_with_linode_interfaces + vpc, subnet = create_vpc_with_subnet + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.public.ipv4.addresses[0].address == instance.ipv4[0] + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + assert len(iface.public.ipv6.ranges) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert ipaddress.ip_address( + iface.vpc.ipv4.addresses[0].address + ) in ipaddress.ip_network(subnet.ipv4) + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None + + assert len(iface.vpc.ipv4.ranges) == 0 + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + __assert_public(instance.interfaces[0]) + __assert_vpc(instance.interfaces[1]) + __assert_vlan(instance.interfaces[2]) + + instance.invalidate() + + __assert_public(instance.interfaces[0]) + __assert_vpc(instance.interfaces[1]) + __assert_vlan(instance.interfaces[2]) + + +@pytest.fixture +def linode_interface_public( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, +): + instance: Instance = linode_with_interface_generation_linode + + ipv6_range = test_linode_client.networking.ipv6_range_allocate( + 64, linode=instance.id + ) + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address=instance.ips.ipv4.public[0].address, + primary=True, + ) + ] + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range=ipv6_range.range, + ) + ] + ), + ), + ), instance, ipv6_range + + +@pytest.fixture +def linode_interface_vpc( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + vpc, subnet = create_vpc_with_subnet + + yield instance.interface_create( + firewall_id=e2e_test_firewall.id, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=subnet.id, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="auto", + primary=True, + nat_1_1_address="auto", + ) + ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions( + range="/32", + ) + ], + ), + ), + ), instance, vpc, subnet + + +@pytest.fixture +def linode_interface_vlan( + test_linode_client, + e2e_test_firewall, + linode_with_interface_generation_linode, + create_vpc_with_subnet, +): + instance: Instance = linode_with_interface_generation_linode + + yield instance.interface_create( + vlan=LinodeInterfaceVLANOptions( + vlan_label="test-vlan", ipam_address="10.0.0.5/32" + ), + ), instance + + +def test_linode_interface_create_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert ( + iface.public.ipv4.addresses[0].address + == instance.ips.ipv4.public[0].address + ) + assert iface.public.ipv4.addresses[0].primary + assert len(iface.public.ipv4.shared) == 0 + + assert iface.public.ipv6.ranges[0].range == ipv6_range.range + assert ( + iface.public.ipv6.ranges[0].route_target == instance.ipv6.split("/")[0] + ) + assert iface.public.ipv6.slaac[0].address == instance.ipv6.split("/")[0] + assert iface.public.ipv6.slaac[0].prefix == 64 + assert len(iface.public.ipv6.shared) == 0 + + +def test_linode_interface_update_public(linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + old_public_ipv4 = copy.deepcopy(iface.public.ipv4) + + iface.public.ipv4.addresses += [ + LinodeInterfacePublicIPv4AddressOptions(address="auto", primary=True) + ] + iface.public.ipv4.addresses[0].primary = False + + iface.public.ipv6.ranges[0].range = "/64" + + iface.save() + + iface.invalidate() + + assert len(iface.public.ipv4.addresses) == 2 + + address = iface.public.ipv4.addresses[0] + assert address.address == old_public_ipv4.addresses[0].address + assert not address.primary + + address = iface.public.ipv4.addresses[1] + assert ipaddress.ip_address(address.address) + assert address.primary + + assert len(iface.public.ipv6.ranges) == 1 + + range = iface.public.ipv6.ranges[0] + assert len(range.range) > 0 + assert ipaddress.ip_network(range.range) + + +def test_linode_interface_create_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses[0].address) > 0 + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is not None + + assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "32" + + +def test_linode_interface_update_vpc(linode_interface_vpc): + iface, instance, vpc, subnet = linode_interface_vpc + + iface.vpc.subnet_id = 0 + + try: + iface.save() + except ApiError: + pass + else: + raise Exception("Expected error when updating subnet_id to 0") + + iface.invalidate() + + old_ipv4 = copy.deepcopy(iface.vpc.ipv4) + + iface.vpc.ipv4.addresses[0].address = "auto" + iface.vpc.ipv4.ranges += [ + LinodeInterfaceVPCIPv4RangeOptions( + range="/32", + ) + ] + + iface.save() + iface.invalidate() + + address = iface.vpc.ipv4.addresses[0] + assert ipaddress.ip_address(address.address) + + range = iface.vpc.ipv4.ranges[0] + assert ipaddress.ip_network(range.range) + assert range.range == old_ipv4.ranges[0].range + + range = iface.vpc.ipv4.ranges[1] + assert ipaddress.ip_network(range.range) + assert range.range != old_ipv4.ranges[0].range + + +def test_linode_interface_create_vlan( + linode_interface_vlan, +): + iface, instance = linode_interface_vlan + + assert iface.id is not None + assert iface.linode_id == instance.id + + assert iface.created is not None + assert iface.updated is not None + + assert isinstance(iface.mac_address, str) + assert iface.version + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "test-vlan" + assert iface.vlan.ipam_address == "10.0.0.5/32" + + +# NOTE: VLAN interface updates current aren't supported + + +def test_linode_interface_firewalls(e2e_test_firewall, linode_interface_public): + iface, instance, ipv6_range = linode_interface_public + + assert iface.id is not None + assert iface.linode_id == instance.id + + firewalls = iface.firewalls() + + firewall = firewalls[0] + assert firewall.id == e2e_test_firewall.id + assert firewall.label == e2e_test_firewall.label diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index e13903e4f..5c1548a57 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -16,6 +16,8 @@ ConfigInterfaceIPv4, Disk, Instance, + InterfaceGeneration, + LinodeInterface, Type, ) from linode_api4.objects.linode import InstanceDiskEncryptionType, MigrationType @@ -66,8 +68,8 @@ def linode_with_volume_firewall(test_linode_client): linode_instance.delete() -@pytest.fixture(scope="session") -def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): +@pytest.fixture(scope="function") +def linode_for_legacy_interface_tests(test_linode_client, e2e_test_firewall): client = test_linode_client region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) @@ -78,6 +80,7 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): image="linode/debian12", label=label, firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, ) yield linode_instance @@ -85,6 +88,29 @@ def linode_for_network_interface_tests(test_linode_client, e2e_test_firewall): linode_instance.delete() +@pytest.fixture(scope="function") +def linode_and_vpc_for_legacy_interface_tests_offline( + test_linode_client, create_vpc_with_subnet, e2e_test_firewall +): + vpc, subnet = create_vpc_with_subnet + + label = get_test_label(length=8) + + instance, password = test_linode_client.linode.instance_create( + "g6-standard-1", + vpc.region, + booted=False, + image="linode/debian11", + label=label, + firewall=e2e_test_firewall, + interface_generation=InterfaceGeneration.LEGACY_CONFIG, + ) + + yield vpc, subnet, instance, password + + instance.delete() + + @pytest.fixture(scope="session") def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): client = test_linode_client @@ -589,6 +615,130 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): assert res +def test_linode_upgrade_interfaces( + linode_for_legacy_interface_tests, + linode_and_vpc_for_legacy_interface_tests_offline, +): + vpc, subnet, linode, _ = linode_and_vpc_for_legacy_interface_tests_offline + config = linode.configs[0] + + new_interfaces = [ + {"purpose": "public"}, + ConfigInterface( + purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" + ), + ConfigInterface( + purpose="vpc", + subnet_id=subnet.id, + primary=True, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ip_ranges=["10.0.0.5/32"], + ), + ] + config.interfaces = new_interfaces + + config.save() + + def __assert_base(iface: LinodeInterface): + assert iface.id is not None + assert iface.created is not None + assert iface.updated is not None + assert iface.version is not None + + assert len(iface.mac_address) > 0 + + def __assert_public(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert len(iface.public.ipv4.addresses) == 0 + assert len(iface.public.ipv4.shared) == 0 + + assert len(iface.public.ipv6.slaac) == 1 + assert iface.public.ipv6.slaac[0].address == linode.ipv6.split("/")[0] + + assert len(iface.public.ipv6.ranges) == 0 + assert len(iface.public.ipv6.shared) == 0 + + def __assert_vpc(iface: LinodeInterface): + __assert_base(iface) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vpc.vpc_id == vpc.id + assert iface.vpc.subnet_id == subnet.id + + assert len(iface.vpc.ipv4.addresses) == 1 + assert iface.vpc.ipv4.addresses[0].address == "10.0.0.2" + assert iface.vpc.ipv4.addresses[0].primary + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is not None + + assert len(iface.vpc.ipv4.ranges) == 1 + assert iface.vpc.ipv4.ranges[0].range == "10.0.0.5/32" + + def __assert_vlan(iface: LinodeInterface): + __assert_base(iface) + + assert not iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.vlan.vlan_label == "cool-vlan" + assert iface.vlan.ipam_address == "10.0.0.4/32" + + result = linode.upgrade_interfaces(dry_run=True) + + assert result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + result = linode.upgrade_interfaces(config=config) + + assert not result.dry_run + assert result.config_id == config.id + + __assert_public(result.interfaces[0]) + __assert_vlan(result.interfaces[1]) + __assert_vpc(result.interfaces[2]) + + __assert_public(linode.interfaces[0]) + __assert_vlan(linode.interfaces[1]) + __assert_vpc(linode.interfaces[2]) + + +def test_linode_interfaces_settings(linode_with_linode_interfaces): + linode = linode_with_linode_interfaces + settings = linode.interfaces_settings + + assert settings.network_helper is not None + assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id + assert settings.default_route.ipv4_eligible_interface_ids == [ + linode.interfaces[0].id, + linode.interfaces[1].id, + ] + + assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id + assert settings.default_route.ipv6_eligible_interface_ids == [ + linode.interfaces[0].id + ] + + # Arbitrary updates + settings.network_helper = True + settings.default_route.ipv4_interface_id = linode.interfaces[1].id + + settings.save() + settings.invalidate() + + # Assert updates + assert settings.network_helper is not None + assert settings.default_route.ipv4_interface_id == linode.interfaces[1].id + + def test_config_update_interfaces(create_linode): linode = create_linode config = linode.configs[0] @@ -672,8 +822,8 @@ def test_save_linode_force(test_linode_client, create_linode): class TestNetworkInterface: - def test_list(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_list(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -693,8 +843,8 @@ def test_list(self, linode_for_network_interface_tests): assert interface[1].label == label assert interface[1].ipam_address == "10.0.0.3/32" - def test_create_public(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_public(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -711,8 +861,8 @@ def test_create_public(self, linode_for_network_interface_tests): assert interface.purpose == "public" assert interface.primary - def test_create_vlan(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_create_vlan(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] @@ -736,10 +886,11 @@ def test_create_vpu(self, test_linode_client, linode_for_vpu_tests): def test_create_vpc( self, test_linode_client, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -749,7 +900,7 @@ def test_create_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ipv4=ConfigInterfaceIPv4(vpc="10.0.0.2", nat_1_1="any"), + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.3", nat_1_1="any"), ip_ranges=["10.0.0.5/32"], ) @@ -758,7 +909,7 @@ def test_create_vpc( assert interface.id == config.interfaces[0].id assert interface.subnet.id == subnet.id assert interface.purpose == "vpc" - assert interface.ipv4.vpc == "10.0.0.2" + assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] assert interface.ip_ranges == ["10.0.0.5/32"] @@ -792,10 +943,11 @@ def test_create_vpc( def test_update_vpc( self, - linode_for_network_interface_tests, - create_vpc_with_subnet_and_linode, + linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode, _ = ( + linode_and_vpc_for_legacy_interface_tests_offline + ) config: Config = linode.configs[0] @@ -805,11 +957,11 @@ def test_update_vpc( interface = config.interface_create_vpc( subnet=subnet, primary=True, - ip_ranges=["10.0.0.5/32"], + ip_ranges=["10.0.0.8/32"], ) interface.primary = False - interface.ip_ranges = ["10.0.0.6/32"] + interface.ip_ranges = ["10.0.0.9/32"] interface.ipv4.vpc = "10.0.0.3" interface.ipv4.nat_1_1 = "any" @@ -822,10 +974,10 @@ def test_update_vpc( assert interface.purpose == "vpc" assert interface.ipv4.vpc == "10.0.0.3" assert interface.ipv4.nat_1_1 == linode.ipv4[0] - assert interface.ip_ranges == ["10.0.0.6/32"] + assert interface.ip_ranges == ["10.0.0.9/32"] - def test_reorder(self, linode_for_network_interface_tests): - linode = linode_for_network_interface_tests + def test_reorder(self, linode_for_legacy_interface_tests): + linode = linode_for_legacy_interface_tests config: Config = linode.configs[0] diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 87a0e5842..0edd5bd0a 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -15,7 +15,11 @@ from linode_api4 import Instance, LinodeClient from linode_api4.objects import Config, ConfigInterfaceIPv4, Firewall, IPAddress -from linode_api4.objects.networking import NetworkTransferPrice, Price +from linode_api4.objects.networking import ( + FirewallCreateDevicesOptions, + NetworkTransferPrice, + Price, +) TEST_REGION = get_region( LinodeClient( @@ -73,6 +77,47 @@ def test_get_networking_rules(test_linode_client, test_firewall): assert "outbound_policy" in str(rules) +@pytest.fixture +def create_linode_without_firewall(test_linode_client): + """ + WARNING: This is specifically reserved for Firewall testing. + Don't use this if the Linode will not be assigned to a firewall. + """ + + client = test_linode_client + region = get_region(client, {"Cloud Firewall"}, "core").id + + label = get_test_label() + + instance = client.linode.instance_create( + "g6-nanode-1", + region, + label=label, + ) + + yield client, instance + + instance.delete() + + +@pytest.fixture +def create_firewall_with_device(create_linode_without_firewall): + client, target_instance = create_linode_without_firewall + + firewall = client.networking.firewall_create( + get_test_label(), + rules={ + "inbound_policy": "DROP", + "outbound_policy": "DROP", + }, + devices=FirewallCreateDevicesOptions(linodes=[target_instance.id]), + ) + + yield firewall, target_instance + + firewall.delete() + + def test_get_networking_rule_versions(test_linode_client, test_firewall): firewall = test_linode_client.load(Firewall, test_firewall.id) @@ -263,3 +308,43 @@ def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): ) assert is_deleted is True + + +def test_create_firewall_with_linode_device(create_firewall_with_device): + firewall, target_instance = create_firewall_with_device + + devices = firewall.devices + + assert len(devices) == 1 + assert devices[0].entity.id == target_instance.id + + +# TODO (Enhanced Interfaces): Add test for interface device + + +def test_get_global_firewall_settings(test_linode_client): + settings = test_linode_client.networking.firewall_settings() + + assert settings.default_firewall_ids is not None + assert all( + k in {"vpc_interface", "public_interface", "linode", "nodebalancer"} + for k in vars(settings.default_firewall_ids).keys() + ) + + +def test_ip_info(test_linode_client, create_linode): + linode = create_linode + + ip_info = test_linode_client.load(IPAddress, linode.ipv4[0]) + + assert ip_info.address == linode.ipv4[0] + assert ip_info.gateway is not None + assert ip_info.linode_id == linode.id + assert ip_info.interface_id is None + assert ip_info.prefix == 24 + assert ip_info.public + assert ip_info.rdns is not None + assert ip_info.region.id == linode.region.id + assert ip_info.subnet_mask is not None + assert ip_info.type == "ipv4" + assert ip_info.vpc_nat_1_1 is None diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index 8a6697c81..a495284fd 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -1,6 +1,14 @@ from test.unit.base import ClientBaseCase - -from linode_api4 import InstancePlacementGroupAssignment +from test.unit.objects.linode_interface_test import ( + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) + +from linode_api4 import ( + InstancePlacementGroupAssignment, + InterfaceGeneration, +) from linode_api4.objects import ConfigInterface @@ -32,7 +40,7 @@ def test_instance_create_with_user_data(self): }, ) - def test_instance_create_with_interfaces(self): + def test_instance_create_with_interfaces_legacy(self): """ Tests that user can pass a list of interfaces on Linode create. """ @@ -46,6 +54,7 @@ def test_instance_create_with_interfaces(self): self.client.linode.instance_create( "us-southeast", "g6-nanode-1", + interface_generation=InterfaceGeneration.LEGACY_CONFIG, interfaces=interfaces, ) @@ -96,6 +105,32 @@ def test_create_with_placement_group(self): m.call_data["placement_group"], {"id": 123, "compliant_only": True} ) + def test_instance_create_with_interfaces_linode(self): + """ + Tests that a Linode can be created alongside multiple LinodeInterfaces. + """ + + interfaces = [ + build_interface_options_public(), + build_interface_options_vpc(), + build_interface_options_vlan(), + ] + + with self.mock_post("linode/instances/124") as m: + self.client.linode.instance_create( + "g6-nanode-1", + "us-mia", + interface_generation=InterfaceGeneration.LINODE, + interfaces=interfaces, + ) + + assert m.call_data == { + "region": "us-mia", + "type": "g6-nanode-1", + "interface_generation": "linode", + "interfaces": [iface._serialize() for iface in interfaces], + } + def test_create_with_maintenance_policy(self): """ Tests that you can create a Linode with a maintenance policy diff --git a/test/unit/groups/networking_test.py b/test/unit/groups/networking_test.py new file mode 100644 index 000000000..72cc95cda --- /dev/null +++ b/test/unit/groups/networking_test.py @@ -0,0 +1,17 @@ +from test.unit.base import ClientBaseCase +from test.unit.objects.firewall_test import FirewallTemplatesTest + + +class NetworkingGroupTest(ClientBaseCase): + """ + Tests methods under the NetworkingGroup class. + """ + + def test_get_templates(self): + templates = self.client.networking.firewall_templates() + + assert templates[0].slug == "public" + FirewallTemplatesTest.assert_rules(templates[0].rules) + + assert templates[1].slug == "vpc" + FirewallTemplatesTest.assert_rules(templates[1].rules) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 3c33a16f2..0b0dfac69 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -1,7 +1,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase -from linode_api4 import LongviewSubscription +from linode_api4 import FirewallCreateDevicesOptions, LongviewSubscription from linode_api4.objects.beta import BetaProgram from linode_api4.objects.linode import Instance from linode_api4.objects.networking import IPAddress @@ -44,7 +44,13 @@ def test_get_account(self): self.assertEqual(a.balance, 0) self.assertEqual( a.capabilities, - ["Linodes", "NodeBalancers", "Block Storage", "Object Storage"], + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Object Storage", + "Linode Interfaces", + ], ) def test_get_regions(self): @@ -63,12 +69,18 @@ def test_get_regions(self): "NodeBalancers", "Block Storage", "Object Storage", + "Linode Interfaces", ], ) else: self.assertEqual( region.capabilities, - ["Linodes", "NodeBalancers", "Block Storage"], + [ + "Linodes", + "NodeBalancers", + "Block Storage", + "Linode Interfaces", + ], ) self.assertEqual(region.status, "ok") self.assertIsNotNone(region.resolvers) @@ -1282,7 +1294,12 @@ def test_firewall_create(self): } f = self.client.networking.firewall_create( - "test-firewall-1", rules, status="enabled" + "test-firewall-1", + rules, + devices=FirewallCreateDevicesOptions( + linodes=[123], nodebalancers=[456], interfaces=[789] + ), + status="enabled", ) self.assertIsNotNone(f) @@ -1297,6 +1314,11 @@ def test_firewall_create(self): "label": "test-firewall-1", "status": "enabled", "rules": rules, + "devices": { + "linodes": [123], + "nodebalancers": [456], + "interfaces": [789], + }, }, ) @@ -1311,6 +1333,47 @@ def test_get_firewalls(self): self.assertEqual(firewall.id, 123) + def test_get_firewall_settings(self): + """ + Tests that firewall settings can be retrieved + """ + settings = self.client.networking.firewall_settings() + + assert settings.default_firewall_ids.vpc_interface == 123 + assert settings.default_firewall_ids.public_interface == 456 + assert settings.default_firewall_ids.linode == 789 + assert settings.default_firewall_ids.nodebalancer == 321 + + settings.invalidate() + + assert settings.default_firewall_ids.vpc_interface == 123 + assert settings.default_firewall_ids.public_interface == 456 + assert settings.default_firewall_ids.linode == 789 + assert settings.default_firewall_ids.nodebalancer == 321 + + def test_update_firewall_settings(self): + """ + Tests that firewall settings can be updated + """ + settings = self.client.networking.firewall_settings() + + settings.default_firewall_ids.vpc_interface = 321 + settings.default_firewall_ids.public_interface = 654 + settings.default_firewall_ids.linode = 987 + settings.default_firewall_ids.nodebalancer = 123 + + with self.mock_put("networking/firewalls/settings") as m: + settings.save() + + assert m.call_data == { + "default_firewall_ids": { + "vpc_interface": 321, + "public_interface": 654, + "linode": 987, + "nodebalancer": 123, + } + } + def test_ip_addresses_share(self): """ Tests that you can submit a correct ip addresses share api request. diff --git a/test/unit/objects/account_test.py b/test/unit/objects/account_test.py index 3e04bcaca..da807d182 100644 --- a/test/unit/objects/account_test.py +++ b/test/unit/objects/account_test.py @@ -3,6 +3,7 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from linode_api4 import AccountSettingsInterfacesForNewLinodes from linode_api4.objects import ( Account, AccountAvailability, @@ -97,6 +98,7 @@ def test_get_account(self): self.assertEqual(account.balance_uninvoiced, 145) self.assertEqual(account.billing_source, "akamai") self.assertEqual(account.euuid, "E1AF5EEC-526F-487D-B317EBEB34C87D71") + self.assertIn("Linode Interfaces", account.capabilities) def test_get_login(self): """ @@ -121,7 +123,32 @@ 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, "linode/migrate") + self.assertEqual( + settings.interfaces_for_new_linodes, + AccountSettingsInterfacesForNewLinodes.linode_default_but_legacy_config_allowed, + ) + + def test_post_account_settings(self): + """ + Tests that account settings can be updated successfully + """ + settings = self.client.account.settings() + + settings.network_helper = True + settings.backups_enabled = False + settings.interfaces_for_new_linodes = ( + AccountSettingsInterfacesForNewLinodes.linode_only + ) + + with self.mock_put("/account/settings") as m: + settings.save() + + assert m.call_data == { + "network_helper": True, + "backups_enabled": False, + "interfaces_for_new_linodes": AccountSettingsInterfacesForNewLinodes.linode_only, + "maintenance_policy": "linode/migrate", + } def test_update_account_settings(self): """ diff --git a/test/unit/objects/firewall_test.py b/test/unit/objects/firewall_test.py index a46ea2750..f4c6efb66 100644 --- a/test/unit/objects/firewall_test.py +++ b/test/unit/objects/firewall_test.py @@ -1,5 +1,6 @@ from test.unit.base import ClientBaseCase +from linode_api4 import FirewallTemplate, MappedObject from linode_api4.objects import Firewall, FirewallDevice @@ -54,6 +55,21 @@ def test_update_rules(self): self.assertEqual(m.call_data, new_rules) + def test_create_device(self): + """ + Tests that firewall devices can be created successfully + """ + + firewall = Firewall(self.client, 123) + + with self.mock_post("networking/firewalls/123/devices/123") as m: + firewall.device_create(123, "linode") + assert m.call_data == {"id": 123, "type": "linode"} + + with self.mock_post("networking/firewalls/123/devices/456") as m: + firewall.device_create(123, "interface") + assert m.call_data == {"id": 123, "type": "interface"} + class FirewallDevicesTest(ClientBaseCase): """ @@ -65,7 +81,28 @@ def test_get_devices(self): Tests that devices can be pulled from a firewall """ firewall = Firewall(self.client, 123) - self.assertEqual(len(firewall.devices), 1) + assert len(firewall.devices) == 2 + + assert firewall.devices[0].created is not None + assert firewall.devices[0].id == 123 + assert firewall.devices[0].updated is not None + + assert firewall.devices[0].entity.id == 123 + assert firewall.devices[0].entity.label == "my-linode" + assert firewall.devices[0].entity.type == "linode" + assert firewall.devices[0].entity.url == "/v4/linode/instances/123" + + assert firewall.devices[1].created is not None + assert firewall.devices[1].id == 456 + assert firewall.devices[1].updated is not None + + assert firewall.devices[1].entity.id == 123 + assert firewall.devices[1].entity.label is None + assert firewall.devices[1].entity.type == "interface" + assert ( + firewall.devices[1].entity.url + == "/v4/linode/instances/123/interfaces/123" + ) def test_get_device(self): """ @@ -81,3 +118,43 @@ def test_get_device(self): self.assertEqual(device.entity.url, "/v4/linode/instances/123") self.assertEqual(device._populated, True) + + +class FirewallTemplatesTest(ClientBaseCase): + @staticmethod + def assert_rules(rules: MappedObject): + assert rules.outbound_policy == "DROP" + assert len(rules.outbound) == 1 + + assert rules.inbound_policy == "DROP" + assert len(rules.inbound) == 1 + + outbound_rule = rules.outbound[0] + assert outbound_rule.action == "ACCEPT" + assert outbound_rule.addresses.ipv4[0] == "192.0.2.0/24" + assert outbound_rule.addresses.ipv4[1] == "198.51.100.2/32" + assert outbound_rule.addresses.ipv6[0] == "2001:DB8::/128" + assert outbound_rule.description == "test" + assert outbound_rule.label == "test-rule" + assert outbound_rule.ports == "22-24, 80, 443" + assert outbound_rule.protocol == "TCP" + + inbound_rule = rules.inbound[0] + assert inbound_rule.action == "ACCEPT" + assert inbound_rule.addresses.ipv4[0] == "192.0.2.0/24" + assert inbound_rule.addresses.ipv4[1] == "198.51.100.2/32" + assert inbound_rule.addresses.ipv6[0] == "2001:DB8::/128" + assert inbound_rule.description == "test" + assert inbound_rule.label == "test-rule" + assert inbound_rule.ports == "22-24, 80, 443" + assert inbound_rule.protocol == "TCP" + + def test_get_public(self): + template = self.client.load(FirewallTemplate, "public") + assert template.slug == "public" + self.assert_rules(template.rules) + + def test_get_vpc(self): + template = self.client.load(FirewallTemplate, "vpc") + assert template.slug == "vpc" + self.assert_rules(template.rules) diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py new file mode 100644 index 000000000..421cfbf55 --- /dev/null +++ b/test/unit/objects/linode_interface_test.py @@ -0,0 +1,307 @@ +from datetime import datetime +from test.unit.base import ClientBaseCase + +from linode_api4 import ( + LinodeInterface, + LinodeInterfaceDefaultRouteOptions, + LinodeInterfaceOptions, + LinodeInterfacePublicIPv4AddressOptions, + LinodeInterfacePublicIPv4Options, + LinodeInterfacePublicIPv6Options, + LinodeInterfacePublicIPv6RangeOptions, + LinodeInterfacePublicOptions, + LinodeInterfaceVLANOptions, + LinodeInterfaceVPCIPv4AddressOptions, + LinodeInterfaceVPCIPv4Options, + LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCOptions, +) + + +def build_interface_options_public(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ipv6=True, + ), + public=LinodeInterfacePublicOptions( + ipv4=LinodeInterfacePublicIPv4Options( + addresses=[ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.50", primary=True + ) + ], + ), + ipv6=LinodeInterfacePublicIPv6Options( + ranges=[ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3c09:e001:59::/64" + ) + ] + ), + ), + ) + + +def build_interface_options_vpc(): + return LinodeInterfaceOptions( + firewall_id=123, + default_route=LinodeInterfaceDefaultRouteOptions( + ipv4=True, + ), + vpc=LinodeInterfaceVPCOptions( + subnet_id=123, + ipv4=LinodeInterfaceVPCIPv4Options( + addresses=[ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.3", + primary=True, + nat_1_1_address="any", + ) + ], + ranges=[ + LinodeInterfaceVPCIPv4RangeOptions(range="192.168.22.16/28") + ], + ), + ), + ) + + +def build_interface_options_vlan(): + return LinodeInterfaceOptions( + vlan=LinodeInterfaceVLANOptions( + vlan_label="my_vlan", ipam_address="10.0.0.1/24" + ), + ) + + +class LinodeInterfaceTest(ClientBaseCase): + """ + Tests methods of the LinodeInterface class + """ + + @staticmethod + def assert_linode_124_interface_123(iface: LinodeInterface): + assert iface.id == 123 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.vpc is None + + # public.ipv4 assertions + assert iface.public.ipv4.addresses[0].address == "172.30.0.50" + assert iface.public.ipv4.addresses[0].primary + + assert iface.public.ipv4.shared[0].address == "172.30.0.51" + assert iface.public.ipv4.shared[0].linode_id == 125 + + # public.ipv6 assertions + assert iface.public.ipv6.ranges[0].range == "2600:3c09:e001:59::/64" + assert ( + iface.public.ipv6.ranges[0].route_target + == "2600:3c09::ff:feab:cdef" + ) + + assert iface.public.ipv6.ranges[1].range == "2600:3c09:e001:5a::/64" + assert ( + iface.public.ipv6.ranges[1].route_target + == "2600:3c09::ff:feab:cdef" + ) + + assert iface.public.ipv6.shared[0].range == "2600:3c09:e001:2a::/64" + assert iface.public.ipv6.shared[0].route_target is None + + assert iface.public.ipv6.slaac[0].address == "2600:3c09::ff:feab:cdef" + assert iface.public.ipv6.slaac[0].prefix == 64 + + @staticmethod + def assert_linode_124_interface_456(iface: LinodeInterface): + assert iface.id == 456 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 + assert not iface.default_route.ipv6 + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.vlan is None + assert iface.public is None + + # vpc assertions + assert iface.vpc.vpc_id == 123456 + assert iface.vpc.subnet_id == 789 + + assert iface.vpc.ipv4.addresses[0].address == "192.168.22.3" + assert iface.vpc.ipv4.addresses[0].primary + + assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" + assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + + @staticmethod + def assert_linode_124_interface_789(iface: LinodeInterface): + assert iface.id == 789 + + assert isinstance(iface.created, datetime) + assert isinstance(iface.updated, datetime) + + assert iface.default_route.ipv4 is None + assert iface.default_route.ipv6 is None + + assert iface.mac_address == "22:00:AB:CD:EF:01" + assert iface.version == 1 + + assert iface.public is None + assert iface.vpc is None + + # vlan assertions + assert iface.vlan.vlan_label == "my_vlan" + assert iface.vlan.ipam_address == "10.0.0.1/24" + + def test_get_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + iface.invalidate() + self.assert_linode_124_interface_123(iface) + + def test_get_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + iface.invalidate() + self.assert_linode_124_interface_456(iface) + + def test_get_vlan(self): + iface = LinodeInterface(self.client, 789, 124) + + self.assert_linode_124_interface_789(iface) + iface.invalidate() + self.assert_linode_124_interface_789(iface) + + def test_update_public(self): + iface = LinodeInterface(self.client, 123, 124) + + self.assert_linode_124_interface_123(iface) + + iface.default_route.ipv4 = False + iface.default_route.ipv6 = False + + iface.public.ipv4.addresses = [ + LinodeInterfacePublicIPv4AddressOptions( + address="172.30.0.51", + primary=False, + ) + ] + + iface.public.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions( + range="2600:3c09:e001:58::/64" + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/123") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + "ipv6": False, + }, + "public": { + "ipv4": { + "addresses": [ + { + "address": "172.30.0.51", + "primary": False, + }, + ] + }, + "ipv6": { + "ranges": [ + { + "range": "2600:3c09:e001:58::/64", + } + ] + }, + }, + } + + def test_update_vpc(self): + iface = LinodeInterface(self.client, 456, 124) + + self.assert_linode_124_interface_456(iface) + + iface.default_route.ipv4 = False + + iface.vpc.subnet_id = 456 + + iface.vpc.ipv4.addresses = [ + LinodeInterfaceVPCIPv4AddressOptions( + address="192.168.22.4", primary=False, nat_1_1_address="auto" + ) + ] + + iface.vpc.ipv4.ranges = [ + LinodeInterfaceVPCIPv4RangeOptions( + range="192.168.22.17/28", + ) + ] + + with self.mock_put("/linode/instances/124/interfaces/456") as m: + iface.save() + + assert m.called + + assert m.call_data == { + "default_route": { + "ipv4": False, + }, + "vpc": { + "subnet_id": 456, + "ipv4": { + "addresses": [ + { + "address": "192.168.22.4", + "primary": False, + "nat_1_1_address": "auto", + }, + ], + "ranges": [{"range": "192.168.22.17/28"}], + }, + }, + } + + def test_delete(self): + iface = LinodeInterface(self.client, 123, 124) + + with self.mock_delete() as m: + iface.delete() + assert m.called + + def test_firewalls(self): + iface = LinodeInterface(self.client, 123, 124) + + firewalls = iface.firewalls() + + assert len(firewalls) == 1 + + assert firewalls[0].id == 123 + + # Check a few fields to make sure the Firewall object was populated + assert firewalls[0].label == "firewall123" + assert firewalls[0].rules.inbound[0].action == "ACCEPT" + assert firewalls[0].status == "enabled" diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 8fa3cdbb3..4945ff423 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -1,7 +1,17 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from test.unit.objects.linode_interface_test import ( + LinodeInterfaceTest, + build_interface_options_public, + build_interface_options_vlan, + build_interface_options_vpc, +) -from linode_api4 import InstanceDiskEncryptionType, NetworkInterface +from linode_api4 import ( + InstanceDiskEncryptionType, + InterfaceGeneration, + NetworkInterface, +) from linode_api4.objects import ( Config, ConfigInterface, @@ -466,6 +476,155 @@ def test_get_placement_group(self): assert pg.label == "test" assert pg.placement_group_type == "anti_affinity:local" + def test_get_interfaces(self): + instance = Instance(self.client, 124) + + assert instance.interface_generation == InterfaceGeneration.LINODE + + interfaces = instance.interfaces + + LinodeInterfaceTest.assert_linode_124_interface_123( + next(iface for iface in interfaces if iface.id == 123) + ) + + LinodeInterfaceTest.assert_linode_124_interface_456( + next(iface for iface in interfaces if iface.id == 456) + ) + + LinodeInterfaceTest.assert_linode_124_interface_789( + next(iface for iface in interfaces if iface.id == 789) + ) + + def test_get_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + assert iface_settings.network_helper + + assert iface_settings.default_route.ipv4_interface_id == 123 + assert iface_settings.default_route.ipv4_eligible_interface_ids == [ + 123, + 456, + 789, + ] + + assert iface_settings.default_route.ipv6_interface_id == 456 + assert iface_settings.default_route.ipv6_eligible_interface_ids == [ + 123, + 456, + ] + + def test_update_interfaces_settings(self): + instance = Instance(self.client, 124) + iface_settings = instance.interfaces_settings + + iface_settings.network_helper = False + iface_settings.default_route.ipv4_interface_id = 456 + iface_settings.default_route.ipv6_interface_id = 123 + + with self.mock_put("/linode/instances/124/interfaces/settings") as m: + iface_settings.save() + + assert m.call_data == { + "network_helper": False, + "default_route": { + "ipv4_interface_id": 456, + "ipv6_interface_id": 123, + }, + } + + def test_upgrade_interfaces(self): + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123) + + assert m.called + assert m.call_data == {"config_id": 123, "dry_run": False} + + assert result.config_id == 123 + assert result.dry_run + + LinodeInterfaceTest.assert_linode_124_interface_123( + result.interfaces[0] + ) + LinodeInterfaceTest.assert_linode_124_interface_456( + result.interfaces[1] + ) + LinodeInterfaceTest.assert_linode_124_interface_789( + result.interfaces[2] + ) + + def test_upgrade_interfaces_dry(self): + instance = Instance(self.client, 124) + + with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: + result = instance.upgrade_interfaces(123, dry_run=True) + + assert m.called + assert m.call_data == { + "config_id": 123, + "dry_run": True, + } + + assert result.config_id == 123 + assert result.dry_run + + # We don't use the assertion helpers here because dry runs return + # a MappedObject. + assert result.interfaces[0].id == 123 + assert result.interfaces[0].public is not None + + assert result.interfaces[1].id == 456 + assert result.interfaces[1].vpc is not None + + assert result.interfaces[2].id == 789 + assert result.interfaces[2].vlan is not None + + def test_create_interface_public(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_public() + + with self.mock_post("/linode/instances/124/interfaces/123") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "public": iface.public._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_123(result) + + def test_create_interface_vpc(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vpc() + + with self.mock_post("/linode/instances/124/interfaces/456") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == { + "firewall_id": iface.firewall_id, + "default_route": iface.default_route._serialize(), + "vpc": iface.vpc._serialize(), + } + + LinodeInterfaceTest.assert_linode_124_interface_456(result) + + def test_create_interface_vlan(self): + instance = Instance(self.client, 124) + + iface = build_interface_options_vlan() + + with self.mock_post("/linode/instances/124/interfaces/789") as m: + result = instance.interface_create(**vars(iface)) + + assert m.call_data == {"vlan": iface.vlan._serialize()} + + LinodeInterfaceTest.assert_linode_124_interface_789(result) + class DiskTest(ClientBaseCase): """ diff --git a/test/unit/objects/networking_test.py b/test/unit/objects/networking_test.py index f982dd6f7..cd2e1b15e 100644 --- a/test/unit/objects/networking_test.py +++ b/test/unit/objects/networking_test.py @@ -121,16 +121,31 @@ def test_rdns_reset(self): self.assertEqual(m.call_data_raw, '{"rdns": null}') - def test_vpc_nat_1_1(self): + def test_get_ip(self): """ - Tests that the vpc_nat_1_1 of an IP can be retrieved. + Tests retrieving comprehensive IP address information, including all relevant properties. """ ip = IPAddress(self.client, "127.0.0.1") - self.assertEqual(ip.vpc_nat_1_1.vpc_id, 242) - self.assertEqual(ip.vpc_nat_1_1.subnet_id, 194) - self.assertEqual(ip.vpc_nat_1_1.address, "139.144.244.36") + def __validate_ip(_ip: IPAddress): + assert _ip.address == "127.0.0.1" + assert _ip.gateway == "127.0.0.1" + assert _ip.linode_id == 123 + assert _ip.interface_id == 456 + assert _ip.prefix == 24 + assert _ip.public + assert _ip.rdns == "test.example.org" + assert _ip.region.id == "us-east" + assert _ip.subnet_mask == "255.255.255.0" + assert _ip.type == "ipv4" + assert _ip.vpc_nat_1_1.vpc_id == 242 + assert _ip.vpc_nat_1_1.subnet_id == 194 + assert _ip.vpc_nat_1_1.address == "139.144.244.36" + + __validate_ip(ip) + ip.invalidate() + __validate_ip(ip) def test_delete_ip(self): """ diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 0bc1afa9e..6ae503098 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -15,7 +15,6 @@ def test_get_region(self): region = Region(self.client, "us-east") self.assertEqual(region.id, "us-east") - self.assertIsNotNone(region.capabilities) self.assertEqual(region.country, "us") self.assertEqual(region.label, "label7") self.assertEqual(region.status, "ok") @@ -28,6 +27,9 @@ def test_get_region(self): region.placement_group_limits.maximum_linodes_per_pg, 5 ) + self.assertIsNotNone(region.capabilities) + self.assertIn("Linode Interfaces", region.capabilities) + def test_region_availability(self): """ Tests that availability for a specific region can be listed and filtered on. diff --git a/test/unit/objects/serializable_test.py b/test/unit/objects/serializable_test.py index 9a775ccf1..f7dff4297 100644 --- a/test/unit/objects/serializable_test.py +++ b/test/unit/objects/serializable_test.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from test.unit.base import ClientBaseCase -from typing import Optional +from typing import Optional, Union -from linode_api4 import Base, JSONObject, Property +from linode_api4 import Base, ExplicitNullValue, JSONObject, Property class JSONObjectTest(ClientBaseCase): @@ -14,18 +14,21 @@ class Foo(JSONObject): foo: Optional[str] = None bar: Optional[str] = None baz: str = None + foobar: Union[str, ExplicitNullValue, None] = None foo = Foo().dict assert foo["foo"] is None assert "bar" not in foo assert foo["baz"] is None + assert "foobar" not in foo - foo = Foo(foo="test", bar="test2", baz="test3").dict + foo = Foo(foo="test", bar="test2", baz="test3", foobar="test4").dict assert foo["foo"] == "test" assert foo["bar"] == "test2" assert foo["baz"] == "test3" + assert foo["foobar"] == "test4" def test_serialize_optional_include_None(self): @dataclass @@ -35,18 +38,23 @@ class Foo(JSONObject): foo: Optional[str] = None bar: Optional[str] = None baz: str = None + foobar: Union[str, ExplicitNullValue, None] = None foo = Foo().dict assert foo["foo"] is None assert foo["bar"] is None assert foo["baz"] is None + assert foo["foobar"] is None - foo = Foo(foo="test", bar="test2", baz="test3").dict + foo = Foo( + foo="test", bar="test2", baz="test3", foobar=ExplicitNullValue() + ).dict assert foo["foo"] == "test" assert foo["bar"] == "test2" assert foo["baz"] == "test3" + assert foo["foobar"] is None def test_serialize_put_class(self): """ diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 5e7be1b69..7888bc101 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -118,11 +118,19 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): "2018-01-01T00:01:01", DATE_FORMAT ) - self.assertEqual(subnet.label, "test-subnet") - self.assertEqual(subnet.ipv4, "10.0.0.0/24") - self.assertEqual(subnet.linodes[0].id, 12345) - self.assertEqual(subnet.created, expected_dt) - self.assertEqual(subnet.updated, expected_dt) + assert subnet.label == "test-subnet" + assert subnet.ipv4 == "10.0.0.0/24" + assert subnet.linodes[0].id == 12345 + assert subnet.created == expected_dt + assert subnet.updated == expected_dt + + assert subnet.linodes[0].interfaces[0].id == 678 + assert subnet.linodes[0].interfaces[0].active + assert subnet.linodes[0].interfaces[0].config_id is None + + assert subnet.linodes[0].interfaces[1].id == 543 + assert not subnet.linodes[0].interfaces[1].active + assert subnet.linodes[0].interfaces[1].config_id is None def test_list_vpc_ips(self): """ From ffdb4387f485a05480808448a4b32e01db0a3afe Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Fri, 26 Sep 2025 12:54:30 -0400 Subject: [PATCH 335/379] Fix firewall device for linode interfaces (#603) * Fix firewall device for linode interfaces * fix test * fix test --- linode_api4/objects/networking.py | 2 +- test/unit/linode_client_test.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index 1219380fc..bf4d42989 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -213,7 +213,7 @@ class FirewallCreateDevicesOptions(JSONObject): linodes: List[int] = field(default_factory=list) nodebalancers: List[int] = field(default_factory=list) - interfaces: List[int] = field(default_factory=list) + linode_interfaces: List[int] = field(default_factory=list) @dataclass diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 0b0dfac69..d87e08894 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -1297,7 +1297,7 @@ def test_firewall_create(self): "test-firewall-1", rules, devices=FirewallCreateDevicesOptions( - linodes=[123], nodebalancers=[456], interfaces=[789] + linodes=[123], nodebalancers=[456], linode_interfaces=[789] ), status="enabled", ) @@ -1317,7 +1317,7 @@ def test_firewall_create(self): "devices": { "linodes": [123], "nodebalancers": [456], - "interfaces": [789], + "linode_interfaces": [789], }, }, ) From 08ffe71026d84031cba99416b778f0f88d1eddab Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 30 Sep 2025 13:01:42 -0400 Subject: [PATCH 336/379] Fix Linode interfaces property (#604) * Rename `interfaces` property to `linode_interfaces` * Return `None` when interface generation is not `linode` * Update docstring * Fix test * Use enum --- linode_api4/objects/linode.py | 7 +++++-- test/unit/objects/linode_test.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index fd4f990de..d14261d74 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -2006,15 +2006,18 @@ def interfaces_settings(self) -> LinodeInterfacesSettings: return self._interfaces_settings @property - def interfaces(self) -> List[LinodeInterface]: + def linode_interfaces(self) -> Optional[list[LinodeInterface]]: """ All interfaces for this Linode. API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-interface - :returns: An ordered list of interfaces under this Linode. + :returns: An ordered list of linode interfaces under this Linode. If the linode is with legacy config interfaces, returns None. + :rtype: Optional[list[LinodeInterface]] """ + if self.interface_generation != InterfaceGeneration.LINODE: + return None if not hasattr(self, "_interfaces"): result = self._client.get( "{}/interfaces".format(Instance.api_endpoint), diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 4945ff423..d20b9f1c0 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -481,7 +481,7 @@ def test_get_interfaces(self): assert instance.interface_generation == InterfaceGeneration.LINODE - interfaces = instance.interfaces + interfaces = instance.linode_interfaces LinodeInterfaceTest.assert_linode_124_interface_123( next(iface for iface in interfaces if iface.id == 123) From d7e05d2a47cd3ce4f38f7e7b49e2ee1de85fc91b Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:38:17 -0400 Subject: [PATCH 337/379] Fix test for interfaces (#605) --- .../linode/interfaces/test_interfaces.py | 12 +++---- test/integration/models/linode/test_linode.py | 31 ++++++++++++------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 07dffd66a..7ec33d957 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -79,15 +79,15 @@ def __assert_vlan(iface: LinodeInterface): assert iface.vlan.vlan_label == "test-vlan" assert iface.vlan.ipam_address == "10.0.0.5/32" - __assert_public(instance.interfaces[0]) - __assert_vpc(instance.interfaces[1]) - __assert_vlan(instance.interfaces[2]) + __assert_public(instance.linode_interfaces[0]) + __assert_vpc(instance.linode_interfaces[1]) + __assert_vlan(instance.linode_interfaces[2]) instance.invalidate() - __assert_public(instance.interfaces[0]) - __assert_vpc(instance.interfaces[1]) - __assert_vlan(instance.interfaces[2]) + __assert_public(instance.linode_interfaces[0]) + __assert_vpc(instance.linode_interfaces[1]) + __assert_vlan(instance.linode_interfaces[2]) @pytest.fixture diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 5c1548a57..50beff592 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -706,37 +706,46 @@ def __assert_vlan(iface: LinodeInterface): __assert_vlan(result.interfaces[1]) __assert_vpc(result.interfaces[2]) - __assert_public(linode.interfaces[0]) - __assert_vlan(linode.interfaces[1]) - __assert_vpc(linode.interfaces[2]) + __assert_public(linode.linode_interfaces[0]) + __assert_vlan(linode.linode_interfaces[1]) + __assert_vpc(linode.linode_interfaces[2]) def test_linode_interfaces_settings(linode_with_linode_interfaces): linode = linode_with_linode_interfaces - settings = linode.interfaces_settings + settings = linode.linode_interfaces_settings assert settings.network_helper is not None - assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id + assert ( + settings.default_route.ipv4_interface_id + == linode.linode_interfaces[0].id + ) assert settings.default_route.ipv4_eligible_interface_ids == [ - linode.interfaces[0].id, - linode.interfaces[1].id, + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, ] - assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id + assert ( + settings.default_route.ipv6_interface_id + == linode.linode_interfaces[0].id + ) assert settings.default_route.ipv6_eligible_interface_ids == [ - linode.interfaces[0].id + linode.linode_interfaces[0].id ] # Arbitrary updates settings.network_helper = True - settings.default_route.ipv4_interface_id = linode.interfaces[1].id + settings.default_route.ipv4_interface_id = linode.linode_interfaces[1].id settings.save() settings.invalidate() # Assert updates assert settings.network_helper is not None - assert settings.default_route.ipv4_interface_id == linode.interfaces[1].id + assert ( + settings.default_route.ipv4_interface_id + == linode.linode_interfaces[1].id + ) def test_config_update_interfaces(create_linode): From c2e74095c85a39552f0cec762d16746c7a313cae Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Wed, 1 Oct 2025 03:01:23 -0400 Subject: [PATCH 338/379] Fix test --- test/integration/models/linode/test_linode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 50beff592..adb237559 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -713,7 +713,7 @@ def __assert_vlan(iface: LinodeInterface): def test_linode_interfaces_settings(linode_with_linode_interfaces): linode = linode_with_linode_interfaces - settings = linode.linode_interfaces_settings + settings = linode.interfaces_settings assert settings.network_helper is not None assert ( From 93056c0b5dee435c24579a3bdfa8aa6ce369d1f7 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 2 Oct 2025 10:36:37 -0400 Subject: [PATCH 339/379] Migrate test fixtures discovery to be with pathlib (#599) * Migrate test fixtures discovery to be with pathlib * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test/unit/fixtures.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/test/unit/fixtures.py b/test/unit/fixtures.py index 52d41d84c..c943da95c 100644 --- a/test/unit/fixtures.py +++ b/test/unit/fixtures.py @@ -1,9 +1,8 @@ import json -import os import re -import sys +from pathlib import Path -FIXTURES_DIR = sys.path[0] + "/test/fixtures" +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" # This regex is useful for finding individual underscore characters, # which is necessary to allow us to use underscores in URL paths. @@ -30,18 +29,18 @@ def _load_fixtures(self): """ self.fixtures = {} - for json_file in os.listdir(FIXTURES_DIR): - if not json_file.endswith(".json"): + for json_file in FIXTURES_DIR.iterdir(): + if json_file.suffix != ".json": continue - with open(FIXTURES_DIR + "/" + json_file) as f: + with open(json_file) as f: raw = f.read() data = json.loads(raw) - fixture_url = PATH_REPLACEMENT_REGEX.sub("/", json_file).replace( - "__", "_" - )[:-5] + fixture_url = PATH_REPLACEMENT_REGEX.sub( + "/", json_file.name + ).replace("__", "_")[:-5] self.fixtures[fixture_url] = data From 7e420db4009cf6807370ae217995b147d92d62f7 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:10:47 -0400 Subject: [PATCH 340/379] project: DBaaS/VPC Integration (#608) * Added support for VPC DBaaS Integration (#560) * Implemented support for VPC DBaaS Integration * Added unit tests * Add support for VPCSubnet.databases field (#607) * Add support for VPCSubnet.databases field * ipv6_range -> ipv6_ranges * ipv6_range -> ipv6_ranges (list) --------- Co-authored-by: Erik Zilber --- linode_api4/groups/database.py | 9 ++++ linode_api4/objects/database.py | 21 +++++++++ linode_api4/objects/vpc.py | 8 ++++ test/fixtures/databases_instances.json | 7 ++- test/fixtures/databases_mysql_instances.json | 5 ++ .../databases_postgresql_instances.json | 5 ++ test/fixtures/vpcs_123456_subnets.json | 9 ++++ test/fixtures/vpcs_123456_subnets_789.json | 9 ++++ test/unit/groups/database_test.py | 11 +++++ test/unit/objects/database_test.py | 46 +++++++++++++++++++ test/unit/objects/vpc_test.py | 4 ++ 11 files changed, 133 insertions(+), 1 deletion(-) diff --git a/linode_api4/groups/database.py b/linode_api4/groups/database.py index 9de02ac35..9546100a8 100644 --- a/linode_api4/groups/database.py +++ b/linode_api4/groups/database.py @@ -9,6 +9,7 @@ from linode_api4.objects import ( Database, DatabaseEngine, + DatabasePrivateNetwork, DatabaseType, MySQLDatabase, PostgreSQLDatabase, @@ -126,6 +127,7 @@ def mysql_create( engine, ltype, engine_config: Union[MySQLDatabaseConfigOptions, Dict[str, Any]] = None, + private_network: Union[DatabasePrivateNetwork, Dict[str, Any]] = None, **kwargs, ): """ @@ -159,6 +161,8 @@ def mysql_create( :type ltype: str or Type :param engine_config: The configuration options for this MySQL cluster :type engine_config: Dict[str, Any] or MySQLDatabaseConfigOptions + :param private_network: The private network settings to use for this cluster + :type private_network: Dict[str, Any] or DatabasePrivateNetwork """ params = { @@ -167,6 +171,7 @@ def mysql_create( "engine": engine, "type": ltype, "engine_config": engine_config, + "private_network": private_network, } params.update(kwargs) @@ -262,6 +267,7 @@ def postgresql_create( engine_config: Union[ PostgreSQLDatabaseConfigOptions, Dict[str, Any] ] = None, + private_network: Union[DatabasePrivateNetwork, Dict[str, Any]] = None, **kwargs, ): """ @@ -295,6 +301,8 @@ def postgresql_create( :type ltype: str or Type :param engine_config: The configuration options for this PostgreSQL cluster :type engine_config: Dict[str, Any] or PostgreSQLDatabaseConfigOptions + :param private_network: The private network settings to use for this cluster + :type private_network: Dict[str, Any] or DatabasePrivateNetwork """ params = { @@ -303,6 +311,7 @@ def postgresql_create( "engine": engine, "type": ltype, "engine_config": engine_config, + "private_network": private_network, } params.update(kwargs) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 39249bbf9..979990e8e 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -74,6 +74,18 @@ def invalidate(self): Base.invalidate(self) +@dataclass +class DatabasePrivateNetwork(JSONObject): + """ + DatabasePrivateNetwork is used to specify + a Database Cluster's private network settings during its creation. + """ + + vpc_id: Optional[int] = None + subnet_id: Optional[int] = None + public_access: Optional[bool] = None + + @deprecated( reason="Backups are not supported for non-legacy database clusters." ) @@ -304,6 +316,9 @@ class MySQLDatabase(Base): "engine_config": Property( mutable=True, json_object=MySQLDatabaseConfigOptions ), + "private_network": Property( + mutable=True, json_object=DatabasePrivateNetwork, nullable=True + ), } @property @@ -470,6 +485,9 @@ class PostgreSQLDatabase(Base): "engine_config": Property( mutable=True, json_object=PostgreSQLDatabaseConfigOptions ), + "private_network": Property( + mutable=True, json_object=DatabasePrivateNetwork, nullable=True + ), } @property @@ -636,6 +654,9 @@ class Database(Base): "updated": Property(), "updates": Property(), "version": Property(), + "private_network": Property( + json_object=DatabasePrivateNetwork, nullable=True + ), } @property diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 94c0302f0..52fdacbce 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -21,6 +21,13 @@ class VPCSubnetLinode(JSONObject): interfaces: Optional[List[VPCSubnetLinodeInterface]] = None +@dataclass +class VPCSubnetDatabase(JSONObject): + id: int = 0 + ipv4_range: Optional[str] = None + ipv6_ranges: Optional[List[str]] = None + + class VPCSubnet(DerivedBase): """ An instance of a VPC subnet. @@ -37,6 +44,7 @@ class VPCSubnet(DerivedBase): "label": Property(mutable=True), "ipv4": Property(), "linodes": Property(json_object=VPCSubnetLinode, unordered=True), + "databases": Property(json_object=VPCSubnetDatabase, unordered=True), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), } diff --git a/test/fixtures/databases_instances.json b/test/fixtures/databases_instances.json index 3b3f4d602..5e92515a5 100644 --- a/test/fixtures/databases_instances.json +++ b/test/fixtures/databases_instances.json @@ -27,7 +27,12 @@ "hour_of_day": 0, "week_of_month": null }, - "version": "8.0.26" + "version": "8.0.26", + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true + } } ], "page": 1, diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json index d6e3f2e64..e60bfe019 100644 --- a/test/fixtures/databases_mysql_instances.json +++ b/test/fixtures/databases_mysql_instances.json @@ -61,6 +61,11 @@ "tmp_table_size": 16777216, "wait_timeout": 28800 } + }, + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true } } ], diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json index 92d5ce945..47573aa12 100644 --- a/test/fixtures/databases_postgresql_instances.json +++ b/test/fixtures/databases_postgresql_instances.json @@ -83,6 +83,11 @@ }, "shared_buffers_percentage": 41.5, "work_mem": 4 + }, + "private_network": { + "vpc_id": 1234, + "subnet_id": 5678, + "public_access": true } } ], diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index 37537efb2..a24642d78 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -21,6 +21,15 @@ ] } ], + "databases": [ + { + "id": 12345, + "ipv4_range": "10.0.0.0/24", + "ipv6_ranges": [ + "2001:db8::/64" + ] + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index 7fac495c4..43a77cd02 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -19,6 +19,15 @@ ] } ], + "databases": [ + { + "id": 12345, + "ipv4_range": "10.0.0.0/24", + "ipv6_ranges": [ + "2001:db8::/64" + ] + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py index 9647fed82..5e2964c8d 100644 --- a/test/unit/groups/database_test.py +++ b/test/unit/groups/database_test.py @@ -61,6 +61,9 @@ def test_get_databases(self): self.assertEqual(dbs[0].region, "us-east") self.assertEqual(dbs[0].updates.duration, 3) self.assertEqual(dbs[0].version, "8.0.26") + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) def test_database_instance(self): """ @@ -1338,6 +1341,10 @@ def test_get_mysql_instances(self): self.assertEqual(dbs[0].engine_config.mysql.tmp_table_size, 16777216) self.assertEqual(dbs[0].engine_config.mysql.wait_timeout, 28800) + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) + def test_get_postgresql_instances(self): """ Test that postgresql instances can be retrieved properly @@ -1452,3 +1459,7 @@ def test_get_postgresql_instances(self): self.assertEqual(dbs[0].engine_config.pg.track_io_timing, "off") self.assertEqual(dbs[0].engine_config.pg.wal_sender_timeout, 60000) self.assertEqual(dbs[0].engine_config.pg.wal_writer_delay, 50) + + self.assertEqual(dbs[0].private_network.vpc_id, 1234) + self.assertEqual(dbs[0].private_network.subnet_id, 5678) + self.assertEqual(dbs[0].private_network.public_access, True) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index c5abe3a58..535b2a336 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -2,6 +2,7 @@ from test.unit.base import ClientBaseCase from linode_api4 import ( + DatabasePrivateNetwork, MySQLDatabaseConfigMySQLOptions, MySQLDatabaseConfigOptions, PostgreSQLDatabase, @@ -41,6 +42,11 @@ def test_create(self): ), binlog_retention_period=200, ), + private_network=DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ), ) except Exception as e: logger.warning( @@ -61,6 +67,12 @@ def test_create(self): m.call_data["engine_config"]["binlog_retention_period"], 200 ) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_update(self): """ Test that the MySQL database can be updated @@ -78,6 +90,11 @@ def test_update(self): mysql=MySQLDatabaseConfigMySQLOptions(connect_timeout=20), binlog_retention_period=200, ) + db.private_network = DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ) db.save() @@ -93,6 +110,12 @@ def test_update(self): m.call_data["engine_config"]["binlog_retention_period"], 200 ) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_list_backups(self): """ Test that MySQL backups list properly @@ -259,6 +282,11 @@ def test_create(self): ), work_mem=4, ), + private_network=DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ), ) except Exception: pass @@ -302,6 +330,12 @@ def test_create(self): ) self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_update(self): """ Test that the PostgreSQL database can be updated @@ -322,6 +356,12 @@ def test_update(self): work_mem=4, ) + db.private_network = DatabasePrivateNetwork( + vpc_id=1234, + subnet_id=5678, + public_access=True, + ) + db.save() self.assertEqual(m.method, "put") @@ -337,6 +377,12 @@ def test_update(self): ) self.assertEqual(m.call_data["engine_config"]["work_mem"], 4) + self.assertEqual(m.call_data["private_network"]["vpc_id"], 1234) + self.assertEqual(m.call_data["private_network"]["subnet_id"], 5678) + self.assertEqual( + m.call_data["private_network"]["public_access"], True + ) + def test_list_backups(self): """ Test that PostgreSQL backups list properly diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index 7888bc101..f69066aa5 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -124,6 +124,10 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): assert subnet.created == expected_dt assert subnet.updated == expected_dt + assert subnet.databases[0].id == 12345 + assert subnet.databases[0].ipv4_range == "10.0.0.0/24" + assert subnet.databases[0].ipv6_ranges == ["2001:db8::/64"] + assert subnet.linodes[0].interfaces[0].id == 678 assert subnet.linodes[0].interfaces[0].active assert subnet.linodes[0].interfaces[0].config_id is None From 9a4ef976af8ecc3a89035c20672fcf21834075f0 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:11:07 -0400 Subject: [PATCH 341/379] project: VPC Dual Stack (#601) * Enhanced Interfaces: Add support for Firewall templates (#529) * Add support for Firewall Templates * oops * Add LA notices * Enhanced Interfaces: Add account-related fields (#525) * Enhanced Interfaces: Add account-related fields * Add setting enum * Add LA notice * Drop residual print * Enhanced Interfaces: Implement endpoints & fields related to VPCs and non-interface networking (#526) * Implement endpoints & fields related to VPCs and non-interface networking * Add LA notices * Implement support for VPC Dual Stack (#524) * Enhanced Interfaces: Add support for Linode-related endpoints and fields (#533) * Add support for Linode-related endpoints and fields * oops * tiny fixes * fix docsa * Add docs examples * Docs fixes * oops * Remove irrelevant test * Add LA notices * Fill in API documentation URLs * Add return types * Enable `include_none_values` in FirewallSettingsDefaultFirewallIDs (#558) * VPC Dual Stack: Support changes related to Linode Interfaces (#559) * Implementation; needs tests * Add integration tests * vpctest * removeprint * test * Fix conflicts * Fix missed conflict --------- Co-authored-by: Zhiwei Liang Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: vshanthe Co-authored-by: Vinay <143587840+vshanthe@users.noreply.github.com> --- linode_api4/groups/vpc.py | 20 ++- linode_api4/objects/linode.py | 120 +++++++++++++--- linode_api4/objects/linode_interfaces.py | 91 ++++++++++-- linode_api4/objects/networking.py | 9 ++ linode_api4/objects/vpc.py | 63 ++++++-- .../linode_instances_123_configs.json | 56 +++++--- .../linode_instances_123_configs_456789.json | 136 ++++++++++-------- ...stances_123_configs_456789_interfaces.json | 74 ++++++---- ...ces_123_configs_456789_interfaces_123.json | 40 ++++-- .../linode_instances_124_interfaces.json | 14 ++ .../linode_instances_124_interfaces_456.json | 18 ++- ...node_instances_124_upgrade-interfaces.json | 14 ++ test/fixtures/vpcs.json | 5 + test/fixtures/vpcs_123456.json | 5 + test/fixtures/vpcs_123456_ips.json | 70 +++++---- test/fixtures/vpcs_123456_subnets.json | 5 + test/fixtures/vpcs_123456_subnets_789.json | 5 + test/fixtures/vpcs_ips.json | 10 ++ test/integration/conftest.py | 13 +- .../linode/interfaces/test_interfaces.py | 24 +++- test/integration/models/linode/test_linode.py | 88 +++++++++--- test/integration/models/vpc/test_vpc.py | 43 +++++- test/unit/objects/linode_interface_test.py | 25 ++++ test/unit/objects/linode_test.py | 123 +++++++++++++++- test/unit/objects/vpc_test.py | 12 ++ 25 files changed, 853 insertions(+), 230 deletions(-) diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index fa8066cea..eda931292 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -2,8 +2,10 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import VPC, Region, VPCIPAddress +from linode_api4.objects import VPC, Region, VPCIPAddress, VPCIPv6RangeOptions +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys class VPCGroup(Group): @@ -33,6 +35,7 @@ def create( region: Union[Region, str], description: Optional[str] = None, subnets: Optional[List[Dict[str, Any]]] = None, + ipv6: Optional[List[Union[VPCIPv6RangeOptions, Dict[str, Any]]]] = None, **kwargs, ) -> VPC: """ @@ -48,6 +51,8 @@ def create( :type description: Optional[str] :param subnets: A list of subnets to create under this VPC. :type subnets: List[Dict[str, Any]] + :param ipv6: The IPv6 address ranges for this VPC. + :type ipv6: List[Union[VPCIPv6RangeOptions, Dict[str, Any]]] :returns: The new VPC object. :rtype: VPC @@ -55,11 +60,11 @@ def create( params = { "label": label, "region": region.id if isinstance(region, Region) else region, + "description": description, + "ipv6": ipv6, + "subnets": subnets, } - if description is not None: - params["description"] = description - if subnets is not None and len(subnets) > 0: for subnet in subnets: if not isinstance(subnet, dict): @@ -67,11 +72,12 @@ def create( f"Unsupported type for subnet: {type(subnet)}" ) - params["subnets"] = subnets - params.update(kwargs) - result = self.client.post("/vpcs", data=params) + result = self.client.post( + "/vpcs", + data=drop_null_keys(_flatten_request_body_recursive(params)), + ) if not "id" in result: raise UnexpectedResponseError( diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index d14261d74..df2694f66 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -300,10 +300,83 @@ def _populate(self, json): @dataclass class ConfigInterfaceIPv4(JSONObject): + """ + ConfigInterfaceIPv4 represents the IPv4 configuration of a VPC interface. + """ + vpc: str = "" nat_1_1: str = "" +@dataclass +class ConfigInterfaceIPv6SLAACOptions(JSONObject): + """ + ConfigInterfaceIPv6SLAACOptions is used to set a single IPv6 SLAAC configuration of a VPC interface. + """ + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6RangeOptions(JSONObject): + """ + ConfigInterfaceIPv6RangeOptions is used to set a single IPv6 range configuration of a VPC interface. + """ + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6Options(JSONObject): + """ + ConfigInterfaceIPv6Options is used to set the IPv6 configuration of a VPC interface. + """ + + slaac: List[ConfigInterfaceIPv6SLAACOptions] = field( + default_factory=lambda: [] + ) + ranges: List[ConfigInterfaceIPv6RangeOptions] = field( + default_factory=lambda: [] + ) + is_public: bool = False + + +@dataclass +class ConfigInterfaceIPv6SLAAC(JSONObject): + """ + ConfigInterfaceIPv6SLAAC represents a single SLAAC address under a VPC interface's IPv6 configuration. + """ + + put_class = ConfigInterfaceIPv6SLAACOptions + + range: str = "" + address: str = "" + + +@dataclass +class ConfigInterfaceIPv6Range(JSONObject): + """ + ConfigInterfaceIPv6Range represents a single IPv6 address under a VPC interface's IPv6 configuration. + """ + + put_class = ConfigInterfaceIPv6RangeOptions + + range: str = "" + + +@dataclass +class ConfigInterfaceIPv6(JSONObject): + """ + ConfigInterfaceIPv6 represents the IPv6 configuration of a VPC interface. + """ + + put_class = ConfigInterfaceIPv6Options + + slaac: List[ConfigInterfaceIPv6SLAAC] = field(default_factory=lambda: []) + ranges: List[ConfigInterfaceIPv6Range] = field(default_factory=lambda: []) + is_public: bool = False + + class NetworkInterface(DerivedBase): """ This class represents a Configuration Profile's network interface object. @@ -329,6 +402,7 @@ class NetworkInterface(DerivedBase): "vpc_id": Property(id_relationship=VPC), "subnet_id": Property(), "ipv4": Property(mutable=True, json_object=ConfigInterfaceIPv4), + "ipv6": Property(mutable=True, json_object=ConfigInterfaceIPv6), "ip_ranges": Property(mutable=True), } @@ -400,7 +474,10 @@ class ConfigInterface(JSONObject): # VPC-specific vpc_id: Optional[int] = None subnet_id: Optional[int] = None + ipv4: Optional[Union[ConfigInterfaceIPv4, Dict[str, Any]]] = None + ipv6: Optional[Union[ConfigInterfaceIPv6, Dict[str, Any]]] = None + ip_ranges: Optional[List[str]] = None # Computed @@ -409,7 +486,7 @@ class ConfigInterface(JSONObject): def __repr__(self): return f"Interface: {self.purpose}" - def _serialize(self, *args, **kwargs): + def _serialize(self, is_put: bool = False): purpose_formats = { "public": {"purpose": "public", "primary": self.primary}, "vlan": { @@ -421,11 +498,8 @@ def _serialize(self, *args, **kwargs): "purpose": "vpc", "primary": self.primary, "subnet_id": self.subnet_id, - "ipv4": ( - self.ipv4.dict - if isinstance(self.ipv4, ConfigInterfaceIPv4) - else self.ipv4 - ), + "ipv4": self.ipv4, + "ipv6": self.ipv6, "ip_ranges": self.ip_ranges, }, } @@ -435,11 +509,14 @@ def _serialize(self, *args, **kwargs): f"Unknown interface purpose: {self.purpose}", ) - return { - k: v - for k, v in purpose_formats[self.purpose].items() - if v is not None - } + return _flatten_request_body_recursive( + { + k: v + for k, v in purpose_formats[self.purpose].items() + if v is not None + }, + is_put=is_put, + ) class Config(DerivedBase): @@ -580,6 +657,7 @@ def interface_create_vpc( subnet: Union[int, VPCSubnet], primary=False, ipv4: Union[Dict[str, Any], ConfigInterfaceIPv4] = None, + ipv6: Union[Dict[str, Any], ConfigInterfaceIPv6Options] = None, ip_ranges: Optional[List[str]] = None, ) -> NetworkInterface: """ @@ -593,6 +671,8 @@ def interface_create_vpc( :type primary: bool :param ipv4: The IPv4 configuration of the interface for the associated subnet. :type ipv4: Dict or ConfigInterfaceIPv4 + :param ipv6: The IPv6 configuration of the interface for the associated subnet. + :type ipv6: Dict or ConfigInterfaceIPv6Options :param ip_ranges: A list of IPs or IP ranges in the VPC subnet. Packets to these CIDRs are routed through the VPC network interface. @@ -603,19 +683,16 @@ def interface_create_vpc( """ params = { "purpose": "vpc", - "subnet_id": subnet.id if isinstance(subnet, VPCSubnet) else subnet, + "subnet_id": subnet, "primary": primary, + "ipv4": ipv4, + "ipv6": ipv6, + "ip_ranges": ip_ranges, } - if ipv4 is not None: - params["ipv4"] = ( - ipv4.dict if isinstance(ipv4, ConfigInterfaceIPv4) else ipv4 - ) - - if ip_ranges is not None: - params["ip_ranges"] = ip_ranges - - return self._interface_create(params) + return self._interface_create( + drop_null_keys(_flatten_request_body_recursive(params)) + ) def interface_reorder(self, interfaces: List[Union[int, NetworkInterface]]): """ @@ -2018,6 +2095,7 @@ def linode_interfaces(self) -> Optional[list[LinodeInterface]]: if self.interface_generation != InterfaceGeneration.LINODE: return None + if not hasattr(self, "_interfaces"): result = self._client.get( "{}/interfaces".format(Instance.api_endpoint), diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 391cb8650..0598d1f3c 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import List, Optional -from linode_api4.objects.base import Base, ExplicitNullValue, Property +from linode_api4.objects.base import Base, Property from linode_api4.objects.dbase import DerivedBase from linode_api4.objects.networking import Firewall from linode_api4.objects.serializable import JSONObject @@ -104,6 +104,41 @@ class LinodeInterfaceVPCIPv4Options(JSONObject): ranges: Optional[List[LinodeInterfaceVPCIPv4RangeOptions]] = None +@dataclass +class LinodeInterfaceVPCIPv6SLAACOptions(JSONObject): + """ + Options accepted for a single SLAAC when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv6RangeOptions(JSONObject): + """ + Options accepted for a single range when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: Optional[str] = None + + +@dataclass +class LinodeInterfaceVPCIPv6Options(JSONObject): + """ + Options accepted when creating or updating the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + is_public: Optional[bool] = None + slaac: Optional[List[LinodeInterfaceVPCIPv6SLAACOptions]] = None + ranges: Optional[List[LinodeInterfaceVPCIPv6RangeOptions]] = None + + @dataclass class LinodeInterfaceVPCOptions(JSONObject): """ @@ -114,6 +149,7 @@ class LinodeInterfaceVPCOptions(JSONObject): subnet_id: int = 0 ipv4: Optional[LinodeInterfaceVPCIPv4Options] = None + ipv6: Optional[LinodeInterfaceVPCIPv6Options] = None @dataclass @@ -193,13 +229,13 @@ class LinodeInterfaceOptions(JSONObject): NOTE: Linode interfaces may not currently be available to all users. """ - # If a default firewall_id isn't configured, the API requires that - # firewall_id is defined in the LinodeInterface POST body. - # - # To create a Linode Interface without a firewall, this field should - # be set to `ExplicitNullValue()`. - firewall_id: Union[int, ExplicitNullValue, None] = None + always_include = { + # If a default firewall_id isn't configured, the API requires that + # firewall_id is defined in the LinodeInterface POST body. + "firewall_id" + } + firewall_id: Optional[int] = None default_route: Optional[LinodeInterfaceDefaultRouteOptions] = None vpc: Optional[LinodeInterfaceVPCOptions] = None public: Optional[LinodeInterfacePublicOptions] = None @@ -265,6 +301,44 @@ class LinodeInterfaceVPCIPv4(JSONObject): ranges: List[LinodeInterfaceVPCIPv4Range] = field(default_factory=list) +@dataclass +class LinodeInterfaceVPCIPv6SLAAC(JSONObject): + """ + A single SLAAC entry under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + address: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv6Range(JSONObject): + """ + A single range under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + range: str = "" + + +@dataclass +class LinodeInterfaceVPCIPv6(JSONObject): + """ + A single address under the IPv6 configuration of a VPC Linode Interface. + + NOTE: Linode interfaces may not currently be available to all users. + """ + + put_class = LinodeInterfaceVPCIPv6Options + + is_public: bool = False + slaac: List[LinodeInterfaceVPCIPv6SLAAC] = field(default_factory=list) + ranges: List[LinodeInterfaceVPCIPv6Range] = field(default_factory=list) + + @dataclass class LinodeInterfaceVPC(JSONObject): """ @@ -279,6 +353,7 @@ class LinodeInterfaceVPC(JSONObject): subnet_id: int = 0 ipv4: Optional[LinodeInterfaceVPCIPv4] = None + ipv6: Optional[LinodeInterfaceVPCIPv6] = None @dataclass diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index bf4d42989..ed975ab71 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -156,6 +156,11 @@ def delete(self): return True +@dataclass +class VPCIPAddressIPv6(JSONObject): + slaac_address: str = "" + + @dataclass class VPCIPAddress(JSONObject): """ @@ -181,6 +186,10 @@ class VPCIPAddress(JSONObject): address_range: Optional[str] = None nat_1_1: Optional[str] = None + ipv6_range: Optional[str] = None + ipv6_is_public: Optional[bool] = None + ipv6_addresses: Optional[List[VPCIPAddressIPv6]] = None + class VLAN(Base): """ diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index 52fdacbce..4adecc2e3 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -1,11 +1,54 @@ from dataclasses import dataclass -from typing import List, Optional +from typing import Any, Dict, List, Optional, Union from linode_api4.errors import UnexpectedResponseError from linode_api4.objects import Base, DerivedBase, Property, Region +from linode_api4.objects.base import _flatten_request_body_recursive from linode_api4.objects.networking import VPCIPAddress from linode_api4.objects.serializable import JSONObject from linode_api4.paginated_list import PaginatedList +from linode_api4.util import drop_null_keys + + +@dataclass +class VPCIPv6RangeOptions(JSONObject): + """ + VPCIPv6RangeOptions is used to specify an IPv6 range when creating or updating a VPC. + """ + + range: str = "" + allocation_class: Optional[str] = None + + +@dataclass +class VPCIPv6Range(JSONObject): + """ + VPCIPv6Range represents a single VPC IPv6 range. + """ + + put_class = VPCIPv6RangeOptions + + range: str = "" + + +@dataclass +class VPCSubnetIPv6RangeOptions(JSONObject): + """ + VPCSubnetIPv6RangeOptions is used to specify an IPv6 range when creating or updating a VPC subnet. + """ + + range: str = "" + + +@dataclass +class VPCSubnetIPv6Range(JSONObject): + """ + VPCSubnetIPv6Range represents a single VPC subnet IPv6 range. + """ + + put_class = VPCSubnetIPv6RangeOptions + + range: str = "" @dataclass @@ -43,6 +86,7 @@ class VPCSubnet(DerivedBase): "id": Property(identifier=True), "label": Property(mutable=True), "ipv4": Property(), + "ipv6": Property(json_object=VPCSubnetIPv6Range, unordered=True), "linodes": Property(json_object=VPCSubnetLinode, unordered=True), "databases": Property(json_object=VPCSubnetDatabase, unordered=True), "created": Property(is_datetime=True), @@ -64,6 +108,7 @@ class VPC(Base): "label": Property(mutable=True), "description": Property(mutable=True), "region": Property(slug_relationship=Region), + "ipv6": Property(json_object=VPCIPv6Range, unordered=True), "subnets": Property(derived_class=VPCSubnet), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), @@ -73,6 +118,9 @@ def subnet_create( self, label: str, ipv4: Optional[str] = None, + ipv6: Optional[ + List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] + ] = None, **kwargs, ) -> VPCSubnet: """ @@ -85,19 +133,16 @@ def subnet_create( :param ipv4: The IPv4 range of this subnet in CIDR format. :type ipv4: str :param ipv6: The IPv6 range of this subnet in CIDR format. - :type ipv6: str + :type ipv6: List[Union[VPCSubnetIPv6RangeOptions, Dict[str, Any]]] """ - params = { - "label": label, - } - - if ipv4 is not None: - params["ipv4"] = ipv4 + params = {"label": label, "ipv4": ipv4, "ipv6": ipv6} params.update(kwargs) result = self._client.post( - "{}/subnets".format(VPC.api_endpoint), model=self, data=params + "{}/subnets".format(VPC.api_endpoint), + model=self, + data=drop_null_keys(_flatten_request_body_recursive(params)), ) self.invalidate() diff --git a/test/fixtures/linode_instances_123_configs.json b/test/fixtures/linode_instances_123_configs.json index 581b84caa..082f8eefd 100644 --- a/test/fixtures/linode_instances_123_configs.json +++ b/test/fixtures/linode_instances_123_configs.json @@ -16,31 +16,45 @@ "id": 456789, "interfaces": [ { - "id": 456, - "purpose": "public", - "primary": true + "id": 456, + "purpose": "public", + "primary": true }, { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true + }, + "ip_ranges": [ + "10.0.0.0/24" + ] }, { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" } ], "run_level": "default", diff --git a/test/fixtures/linode_instances_123_configs_456789.json b/test/fixtures/linode_instances_123_configs_456789.json index 93e41f86b..8f4387af9 100644 --- a/test/fixtures/linode_instances_123_configs_456789.json +++ b/test/fixtures/linode_instances_123_configs_456789.json @@ -1,65 +1,79 @@ { - "root_device":"/dev/sda", - "comments":"", - "helpers":{ - "updatedb_disabled":true, - "modules_dep":true, - "devtmpfs_automount":true, - "distro":true, - "network":false - }, - "label":"My Ubuntu 17.04 LTS Profile", - "created":"2014-10-07T20:04:00", - "memory_limit":0, - "id":456789, - "interfaces": [ - { - "id": 456, - "purpose": "public", - "primary": true + "root_device": "/dev/sda", + "comments": "", + "helpers": { + "updatedb_disabled": true, + "modules_dep": true, + "devtmpfs_automount": true, + "distro": true, + "network": false + }, + "label": "My Ubuntu 17.04 LTS Profile", + "created": "2014-10-07T20:04:00", + "memory_limit": 0, + "id": 456789, + "interfaces": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" }, - { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true }, - { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" - } - ], - "run_level":"default", - "initrd":null, - "virt_mode":"paravirt", - "kernel":"linode/latest-64bit", - "updated":"2014-10-07T20:04:00", - "devices":{ - "sda":{ - "disk_id":12345, - "volume_id":null - }, - "sdc":null, - "sde":null, - "sdh":null, - "sdg":null, - "sdb":{ - "disk_id":12346, - "volume_id":null - }, - "sdf":null, - "sdd":null - } + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" + } + ], + "run_level": "default", + "initrd": null, + "virt_mode": "paravirt", + "kernel": "linode/latest-64bit", + "updated": "2014-10-07T20:04:00", + "devices": { + "sda": { + "disk_id": 12345, + "volume_id": null + }, + "sdc": null, + "sde": null, + "sdh": null, + "sdg": null, + "sdb": { + "disk_id": 12346, + "volume_id": null + }, + "sdf": null, + "sdd": null + } } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces.json b/test/fixtures/linode_instances_123_configs_456789_interfaces.json index 86c709071..120551365 100644 --- a/test/fixtures/linode_instances_123_configs_456789_interfaces.json +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces.json @@ -1,34 +1,48 @@ { - "data": [ - { - "id": 456, - "purpose": "public", - "primary": true + "data": [ + { + "id": 456, + "purpose": "public", + "primary": true + }, + { + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" }, - { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true }, - { - "id": 321, - "primary": false, - "ipam_address":"10.0.0.2", - "label":"test-interface", - "purpose":"vlan" - } - ], - "page": 1, - "pages": 1, - "results": 1 + "ip_ranges": [ + "10.0.0.0/24" + ] + }, + { + "id": 321, + "primary": false, + "ipam_address": "10.0.0.2", + "label": "test-interface", + "purpose": "vlan" + } + ], + "page": 1, + "pages": 1, + "results": 1 } \ No newline at end of file diff --git a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json index d02673aeb..c120905b2 100644 --- a/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json +++ b/test/fixtures/linode_instances_123_configs_456789_interfaces_123.json @@ -1,15 +1,29 @@ { - "id": 123, - "purpose": "vpc", - "primary": true, - "active": true, - "vpc_id": 123456, - "subnet_id": 789, - "ipv4": { - "vpc": "10.0.0.2", - "nat_1_1": "any" - }, - "ip_ranges": [ - "10.0.0.0/24" - ] + "id": 123, + "purpose": "vpc", + "primary": true, + "active": true, + "vpc_id": 123456, + "subnet_id": 789, + "ipv4": { + "vpc": "10.0.0.2", + "nat_1_1": "any" + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "1234::5678/64" + } + ], + "is_public": true + }, + "ip_ranges": [ + "10.0.0.0/24" + ] } \ No newline at end of file diff --git a/test/fixtures/linode_instances_124_interfaces.json b/test/fixtures/linode_instances_124_interfaces.json index 890e5c84d..dbb6f79fb 100644 --- a/test/fixtures/linode_instances_124_interfaces.json +++ b/test/fixtures/linode_instances_124_interfaces.json @@ -80,6 +80,20 @@ "range": "192.168.22.32/28" } ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] } }, "public": null, diff --git a/test/fixtures/linode_instances_124_interfaces_456.json b/test/fixtures/linode_instances_124_interfaces_456.json index 7fc4f56f8..8ec4abd3d 100644 --- a/test/fixtures/linode_instances_124_interfaces_456.json +++ b/test/fixtures/linode_instances_124_interfaces_456.json @@ -10,7 +10,7 @@ "vpc": { "vpc_id": 123456, "subnet_id": 789, - "ipv4" : { + "ipv4": { "addresses": [ { "address": "192.168.22.3", @@ -20,7 +20,21 @@ "ranges": [ { "range": "192.168.22.16/28"}, { "range": "192.168.22.32/28"} - ] + ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] } }, "public": null, diff --git a/test/fixtures/linode_instances_124_upgrade-interfaces.json b/test/fixtures/linode_instances_124_upgrade-interfaces.json index ad1b3d035..fa1015029 100644 --- a/test/fixtures/linode_instances_124_upgrade-interfaces.json +++ b/test/fixtures/linode_instances_124_upgrade-interfaces.json @@ -82,6 +82,20 @@ "range": "192.168.22.32/28" } ] + }, + "ipv6": { + "is_public": true, + "slaac": [ + { + "range": "1234::/64", + "address": "1234::5678" + } + ], + "ranges": [ + { + "range": "4321::/64" + } + ] } }, "public": null, diff --git a/test/fixtures/vpcs.json b/test/fixtures/vpcs.json index 9a7cc5038..822f3bae1 100644 --- a/test/fixtures/vpcs.json +++ b/test/fixtures/vpcs.json @@ -5,6 +5,11 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } diff --git a/test/fixtures/vpcs_123456.json b/test/fixtures/vpcs_123456.json index e4c16437a..af6d2cff8 100644 --- a/test/fixtures/vpcs_123456.json +++ b/test/fixtures/vpcs_123456.json @@ -3,6 +3,11 @@ "id": 123456, "description": "A very real VPC.", "region": "us-southeast", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "created": "2018-01-01T00:01:01", "updated": "2018-01-01T00:01:01" } \ No newline at end of file diff --git a/test/fixtures/vpcs_123456_ips.json b/test/fixtures/vpcs_123456_ips.json index 70b4b8a60..10cb94f3c 100644 --- a/test/fixtures/vpcs_123456_ips.json +++ b/test/fixtures/vpcs_123456_ips.json @@ -1,34 +1,44 @@ { - "data": [ + "data": [ + { + "address": "10.0.0.2", + "address_range": null, + "vpc_id": 123456, + "subnet_id": 654321, + "region": "us-ord", + "linode_id": 111, + "config_id": 222, + "interface_id": 333, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "address": "10.0.0.3", + "address_range": null, + "vpc_id": 41220, + "subnet_id": 41184, + "region": "us-ord", + "linode_id": 56323949, + "config_id": 59467106, + "interface_id": 1248358, + "active": true, + "nat_1_1": null, + "gateway": "10.0.0.1", + "prefix": 8, + "subnet_mask": "255.0.0.0" + }, + { + "ipv6_range": "fd71:1140:a9d0::/52", + "ipv6_is_public": true, + "ipv6_addresses": [ { - "address": "10.0.0.2", - "address_range": null, - "vpc_id": 123456, - "subnet_id": 654321, - "region": "us-ord", - "linode_id": 111, - "config_id": 222, - "interface_id": 333, - "active": true, - "nat_1_1": null, - "gateway": "10.0.0.1", - "prefix": 8, - "subnet_mask": "255.0.0.0" - }, - { - "address": "10.0.0.3", - "address_range": null, - "vpc_id": 41220, - "subnet_id": 41184, - "region": "us-ord", - "linode_id": 56323949, - "config_id": 59467106, - "interface_id": 1248358, - "active": true, - "nat_1_1": null, - "gateway": "10.0.0.1", - "prefix": 8, - "subnet_mask": "255.0.0.0" + "slaac_address": "fd71:1140:a9d0::/52" } - ] + ], + "vpc_id": 123456 + } + ] } diff --git a/test/fixtures/vpcs_123456_subnets.json b/test/fixtures/vpcs_123456_subnets.json index a24642d78..8239daec2 100644 --- a/test/fixtures/vpcs_123456_subnets.json +++ b/test/fixtures/vpcs_123456_subnets.json @@ -4,6 +4,11 @@ "label": "test-subnet", "id": 789, "ipv4": "10.0.0.0/24", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "linodes": [ { "id": 12345, diff --git a/test/fixtures/vpcs_123456_subnets_789.json b/test/fixtures/vpcs_123456_subnets_789.json index 43a77cd02..199156130 100644 --- a/test/fixtures/vpcs_123456_subnets_789.json +++ b/test/fixtures/vpcs_123456_subnets_789.json @@ -2,6 +2,11 @@ "label": "test-subnet", "id": 789, "ipv4": "10.0.0.0/24", + "ipv6": [ + { + "range": "fd71:1140:a9d0::/52" + } + ], "linodes": [ { "id": 12345, diff --git a/test/fixtures/vpcs_ips.json b/test/fixtures/vpcs_ips.json index d6f16c2e9..7849f5d76 100644 --- a/test/fixtures/vpcs_ips.json +++ b/test/fixtures/vpcs_ips.json @@ -14,6 +14,16 @@ "gateway": "10.0.0.1", "prefix": 24, "subnet_mask": "255.255.255.0" + }, + { + "ipv6_range": "fd71:1140:a9d0::/52", + "ipv6_is_public": true, + "ipv6_addresses": [ + { + "slaac_address": "fd71:1140:a9d0::/52" + } + ], + "vpc_id": 123456 } ], "page": 1, diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 9d2ec0eca..3692269dc 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -406,9 +406,12 @@ def create_vpc(test_linode_client): label = get_test_label(length=10) vpc = client.vpcs.create( - label, - get_region(test_linode_client, {"VPCs"}), + label=label, + region=get_region( + test_linode_client, {"VPCs", "VPC IPv6 Stack", "Linode Interfaces"} + ), description="test description", + ipv6=[{"range": "auto"}], ) yield vpc @@ -417,7 +420,11 @@ def create_vpc(test_linode_client): @pytest.fixture(scope="session") def create_vpc_with_subnet(test_linode_client, create_vpc): - subnet = create_vpc.subnet_create("test-subnet", ipv4="10.0.0.0/24") + subnet = create_vpc.subnet_create( + label="test-subnet", + ipv4="10.0.0.0/24", + ipv6=[{"range": "auto"}], + ) yield create_vpc, subnet diff --git a/test/integration/models/linode/interfaces/test_interfaces.py b/test/integration/models/linode/interfaces/test_interfaces.py index 7ec33d957..650a9cb6c 100644 --- a/test/integration/models/linode/interfaces/test_interfaces.py +++ b/test/integration/models/linode/interfaces/test_interfaces.py @@ -70,6 +70,13 @@ def __assert_vpc(iface: LinodeInterface): assert len(iface.vpc.ipv4.ranges) == 0 + slaac_entry = iface.vpc.ipv6.slaac[0] + assert ipaddress.ip_address( + slaac_entry.address + ) in ipaddress.ip_network(slaac_entry.range) + assert not iface.vpc.ipv6.is_public + assert len(iface.vpc.ipv6.ranges) == 0 + def __assert_vlan(iface: LinodeInterface): __assert_base(iface) @@ -150,7 +157,7 @@ def linode_interface_vpc( LinodeInterfaceVPCIPv4AddressOptions( address="auto", primary=True, - nat_1_1_address="auto", + nat_1_1_address=None, ) ], ranges=[ @@ -256,17 +263,28 @@ def test_linode_interface_create_vpc(linode_interface_vpc): assert iface.version assert iface.default_route.ipv4 - assert not iface.default_route.ipv6 + assert iface.default_route.ipv6 assert iface.vpc.vpc_id == vpc.id assert iface.vpc.subnet_id == subnet.id assert len(iface.vpc.ipv4.addresses[0].address) > 0 assert iface.vpc.ipv4.addresses[0].primary - assert iface.vpc.ipv4.addresses[0].nat_1_1_address is not None + + assert iface.vpc.ipv4.addresses[0].nat_1_1_address is None assert iface.vpc.ipv4.ranges[0].range.split("/")[1] == "32" + assert iface.default_route.ipv6 + ipv6 = iface.vpc.ipv6 + assert ipv6 and ipv6.is_public is False + + if ipv6.slaac: + assert ipv6.ranges == [] and len(ipv6.slaac) == 1 + assert ipv6.slaac[0].range and ipv6.slaac[0].address + elif ipv6.ranges: + assert ipv6.slaac == [] and len(ipv6.ranges) > 0 + def test_linode_interface_update_vpc(linode_interface_vpc): iface, instance, vpc, subnet = linode_interface_vpc diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index adb237559..1413e12d5 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1,3 +1,4 @@ +import ipaddress import time from test.integration.conftest import get_region from test.integration.helpers import ( @@ -651,7 +652,7 @@ def __assert_public(iface: LinodeInterface): __assert_base(iface) assert not iface.default_route.ipv4 - assert iface.default_route.ipv6 + assert not iface.default_route.ipv6 assert len(iface.public.ipv4.addresses) == 0 assert len(iface.public.ipv4.shared) == 0 @@ -666,7 +667,7 @@ def __assert_vpc(iface: LinodeInterface): __assert_base(iface) assert iface.default_route.ipv4 - assert not iface.default_route.ipv6 + assert iface.default_route.ipv6 assert iface.vpc.vpc_id == vpc.id assert iface.vpc.subnet_id == subnet.id @@ -679,6 +680,14 @@ def __assert_vpc(iface: LinodeInterface): assert len(iface.vpc.ipv4.ranges) == 1 assert iface.vpc.ipv4.ranges[0].range == "10.0.0.5/32" + assert len(iface.vpc.ipv6.slaac) == 1 + + ipaddress.IPv6Network(iface.vpc.ipv6.slaac[0].range) + ipaddress.IPv6Address(iface.vpc.ipv6.slaac[0].address) + + assert len(iface.vpc.ipv6.ranges) == 0 + assert iface.vpc.ipv6.is_public is False + def __assert_vlan(iface: LinodeInterface): __assert_base(iface) @@ -702,10 +711,6 @@ def __assert_vlan(iface: LinodeInterface): assert not result.dry_run assert result.config_id == config.id - __assert_public(result.interfaces[0]) - __assert_vlan(result.interfaces[1]) - __assert_vpc(result.interfaces[2]) - __assert_public(linode.linode_interfaces[0]) __assert_vlan(linode.linode_interfaces[1]) __assert_vpc(linode.linode_interfaces[2]) @@ -716,21 +721,15 @@ def test_linode_interfaces_settings(linode_with_linode_interfaces): settings = linode.interfaces_settings assert settings.network_helper is not None - assert ( - settings.default_route.ipv4_interface_id - == linode.linode_interfaces[0].id - ) + assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id assert settings.default_route.ipv4_eligible_interface_ids == [ - linode.linode_interfaces[0].id, - linode.linode_interfaces[1].id, + linode.interfaces[0].id, + linode.interfaces[1].id, ] - assert ( - settings.default_route.ipv6_interface_id - == linode.linode_interfaces[0].id - ) + assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id assert settings.default_route.ipv6_eligible_interface_ids == [ - linode.linode_interfaces[0].id + linode.interfaces[0].id ] # Arbitrary updates @@ -936,11 +935,30 @@ def test_create_vpc( assert vpc_range_ip.address_range == "10.0.0.5/32" assert not vpc_range_ip.active + assert isinstance(vpc.ipv6, list) + assert len(vpc.ipv6) > 0 + assert isinstance(vpc.ipv6[0].range, str) + assert ":" in vpc.ipv6[0].range + # TODO:: Add `VPCIPAddress.filters.linode_id == linode.id` filter back # Attempt to resolve the IP from /vpcs/ips all_vpc_ips = test_linode_client.vpcs.ips() - assert all_vpc_ips[0].dict == vpc_ip.dict + matched_ip = next( + ( + ip + for ip in all_vpc_ips + if ip.address == vpc_ip.address + and ip.vpc_id == vpc_ip.vpc_id + and ip.linode_id == vpc_ip.linode_id + ), + None, + ) + + assert ( + matched_ip is not None + ), f"Expected VPC IP {vpc_ip.address} not found in /vpcs/ips" + assert matched_ip.dict == vpc_ip.dict # Test getting the ips under this specific VPC vpc_ips = vpc.ips @@ -950,6 +968,40 @@ def test_create_vpc( assert vpc_ips[0].linode_id == linode.id assert vpc_ips[0].nat_1_1 == linode.ips.ipv4.public[0].address + # Validate VPC IPv6 IPs from /vpcs/ips + all_vpc_ipv6 = test_linode_client.get("/vpcs/ipv6s")["data"] + + # Find matching VPC IPv6 entry + matched_ipv6 = next( + ( + ip + for ip in all_vpc_ipv6 + if ip["vpc_id"] == vpc.id + and ip["linode_id"] == linode.id + and ip["interface_id"] == interface.id + and ip["subnet_id"] == subnet.id + ), + None, + ) + + assert ( + matched_ipv6 + ), f"No VPC IPv6 found for Linode {linode.id} in VPC {vpc.id}" + + assert matched_ipv6["ipv6_range"].count(":") >= 2 + assert not matched_ipv6["ipv6_is_public"] + + ipv6_addresses = matched_ipv6.get("ipv6_addresses", []) + assert ( + isinstance(ipv6_addresses, list) and ipv6_addresses + ), "No IPv6 addresses found" + + slaac = ipv6_addresses[0] + assert ( + isinstance(slaac.get("slaac_address"), str) + and ":" in slaac["slaac_address"] + ) + def test_update_vpc( self, linode_and_vpc_for_legacy_interface_tests_offline, diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index 0e9d27aff..ee35929b0 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -10,6 +10,7 @@ def test_get_vpc(test_linode_client, create_vpc): vpc = test_linode_client.load(VPC, create_vpc.id) test_linode_client.vpcs() assert vpc.id == create_vpc.id + assert isinstance(vpc.ipv6[0].range, str) @pytest.mark.smoke @@ -31,7 +32,11 @@ def test_update_vpc(test_linode_client, create_vpc): def test_get_subnet(test_linode_client, create_vpc_with_subnet): vpc, subnet = create_vpc_with_subnet loaded_subnet = test_linode_client.load(VPCSubnet, subnet.id, vpc.id) - + assert loaded_subnet.ipv4 == subnet.ipv4 + assert loaded_subnet.ipv6 is not None + assert loaded_subnet.ipv6[0].range.startswith( + vpc.ipv6[0].range.split("::")[0] + ) assert loaded_subnet.id == subnet.id @@ -86,6 +91,9 @@ def test_fails_create_subnet_invalid_data(create_vpc): create_vpc.subnet_create("test-subnet", ipv4=invalid_ipv4) assert excinfo.value.status == 400 + error_msg = str(excinfo.value.json) + + assert "Must be an IPv4 network" in error_msg def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): @@ -97,3 +105,36 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): subnet.save() assert excinfo.value.status == 400 + assert "Label must include only ASCII" in str(excinfo.value.json) + + +def test_fails_create_subnet_with_invalid_ipv6_range(create_vpc): + valid_ipv4 = "10.0.0.0/24" + invalid_ipv6 = [{"range": "2600:3c11:e5b9::/5a"}] + + with pytest.raises(ApiError) as excinfo: + create_vpc.subnet_create( + label="bad-ipv6-subnet", + ipv4=valid_ipv4, + ipv6=invalid_ipv6, + ) + + assert excinfo.value.status == 400 + error = excinfo.value.json["errors"] + + assert any( + e["field"] == "ipv6[0].range" + and "Must be an IPv6 network" in e["reason"] + for e in error + ) + + +def test_get_vpc_ipv6s(test_linode_client): + ipv6s = test_linode_client.get("/vpcs/ipv6s")["data"] + + assert isinstance(ipv6s, list) + + for ipv6 in ipv6s: + assert "vpc_id" in ipv6 + assert isinstance(ipv6["ipv6_range"], str) + assert isinstance(ipv6["ipv6_addresses"], list) diff --git a/test/unit/objects/linode_interface_test.py b/test/unit/objects/linode_interface_test.py index 421cfbf55..c021334e1 100644 --- a/test/unit/objects/linode_interface_test.py +++ b/test/unit/objects/linode_interface_test.py @@ -14,6 +14,7 @@ LinodeInterfaceVPCIPv4AddressOptions, LinodeInterfaceVPCIPv4Options, LinodeInterfaceVPCIPv4RangeOptions, + LinodeInterfaceVPCIPv6SLAACOptions, LinodeInterfaceVPCOptions, ) @@ -149,6 +150,13 @@ def assert_linode_124_interface_456(iface: LinodeInterface): assert iface.vpc.ipv4.ranges[0].range == "192.168.22.16/28" assert iface.vpc.ipv4.ranges[1].range == "192.168.22.32/28" + assert iface.vpc.ipv6.is_public + + assert iface.vpc.ipv6.slaac[0].range == "1234::/64" + assert iface.vpc.ipv6.slaac[0].address == "1234::5678" + + assert iface.vpc.ipv6.ranges[0].range == "4321::/64" + @staticmethod def assert_linode_124_interface_789(iface: LinodeInterface): assert iface.id == 789 @@ -261,6 +269,18 @@ def test_update_vpc(self): ) ] + iface.vpc.ipv6.is_public = False + + iface.vpc.ipv6.slaac = [ + LinodeInterfaceVPCIPv6SLAACOptions( + range="1233::/64", + ) + ] + + iface.vpc.ipv6.ranges = [ + LinodeInterfacePublicIPv6RangeOptions(range="9876::/64") + ] + with self.mock_put("/linode/instances/124/interfaces/456") as m: iface.save() @@ -282,6 +302,11 @@ def test_update_vpc(self): ], "ranges": [{"range": "192.168.22.17/28"}], }, + "ipv6": { + "is_public": False, + "slaac": [{"range": "1233::/64"}], + "ranges": [{"range": "9876::/64"}], + }, }, } diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index d20b9f1c0..40bbb5069 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -16,6 +16,12 @@ Config, ConfigInterface, ConfigInterfaceIPv4, + ConfigInterfaceIPv6, + ConfigInterfaceIPv6Options, + ConfigInterfaceIPv6Range, + ConfigInterfaceIPv6RangeOptions, + ConfigInterfaceIPv6SLAAC, + ConfigInterfaceIPv6SLAACOptions, Disk, Image, Instance, @@ -477,6 +483,11 @@ def test_get_placement_group(self): assert pg.placement_group_type == "anti_affinity:local" def test_get_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + instance = Instance(self.client, 124) assert instance.interface_generation == InterfaceGeneration.LINODE @@ -534,6 +545,11 @@ def test_update_interfaces_settings(self): } def test_upgrade_interfaces(self): + # Local import to avoid circular dependency + from linode_interface_test import ( # pylint: disable=import-outside-toplevel + LinodeInterfaceTest, + ) + instance = Instance(self.client, 124) with self.mock_post("/linode/instances/124/upgrade-interfaces") as m: @@ -665,15 +681,62 @@ def test_update_interfaces(self): new_interfaces = [ {"purpose": "public", "primary": True}, ConfigInterface("vlan", label="cool-vlan"), + ConfigInterface( + "vpc", + vpc_id=18881, + subnet_id=123, + ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ipv6=ConfigInterfaceIPv6( + slaac=[ + ConfigInterfaceIPv6SLAAC( + range="1234::5678/64", address="1234::5678" + ) + ], + ranges=[ + ConfigInterfaceIPv6Range(range="1234::5678/64") + ], + is_public=True, + ), + ), ] - expected_body = [new_interfaces[0], new_interfaces[1]._serialize()] config.interfaces = new_interfaces config.save() - self.assertEqual(m.call_url, "/linode/instances/123/configs/456789") - self.assertEqual(m.call_data.get("interfaces"), expected_body) + assert m.call_url == "/linode/instances/123/configs/456789" + assert m.call_data.get("interfaces") == [ + { + "purpose": "public", + "primary": True, + }, + { + "purpose": "vlan", + "label": "cool-vlan", + }, + { + "purpose": "vpc", + "subnet_id": 123, + "ipv4": { + "vpc": "10.0.0.4", + "nat_1_1": "any", + }, + "ipv6": { + "slaac": [ + { + "range": "1234::5678/64", + # NOTE: Address is read-only so it shouldn't be specified here + } + ], + "ranges": [ + { + "range": "1234::5678/64", + } + ], + "is_public": True, + }, + }, + ] def test_get_config(self): json = self.client.get("/linode/instances/123/configs/456789") @@ -703,6 +766,24 @@ def test_interface_ipv4(self): self.assertEqual(ipv4.vpc, "10.0.0.1") self.assertEqual(ipv4.nat_1_1, "any") + def test_interface_ipv6(self): + json = { + "slaac": [{"range": "1234::5678/64", "address": "1234::5678"}], + "ranges": [{"range": "1234::5678/64"}], + "is_public": True, + } + + ipv6 = ConfigInterfaceIPv6.from_json(json) + + assert len(ipv6.slaac) == 1 + assert ipv6.slaac[0].range == "1234::5678/64" + assert ipv6.slaac[0].address == "1234::5678" + + assert len(ipv6.ranges) == 1 + assert ipv6.ranges[0].range == "1234::5678/64" + + assert ipv6.is_public + def test_config_devices_unwrap(self): """ Tests that config devices can be successfully converted to a dict. @@ -906,6 +987,11 @@ def test_create_interface_vpc(self): subnet=VPCSubnet(self.client, 789, 123456), primary=True, ipv4=ConfigInterfaceIPv4(vpc="10.0.0.4", nat_1_1="any"), + ipv6=ConfigInterfaceIPv6Options( + slaac=[ConfigInterfaceIPv6SLAACOptions(range="auto")], + ranges=[ConfigInterfaceIPv6RangeOptions(range="auto")], + is_public=True, + ), ip_ranges=["10.0.0.0/24"], ) @@ -919,6 +1005,11 @@ def test_create_interface_vpc(self): "primary": True, "subnet_id": 789, "ipv4": {"vpc": "10.0.0.4", "nat_1_1": "any"}, + "ipv6": { + "slaac": [{"range": "auto"}], + "ranges": [{"range": "auto"}], + "is_public": True, + }, "ip_ranges": ["10.0.0.0/24"], } @@ -927,8 +1018,19 @@ def test_create_interface_vpc(self): assert interface.primary assert interface.vpc.id == 123456 assert interface.subnet.id == 789 + assert interface.ipv4.vpc == "10.0.0.2" assert interface.ipv4.nat_1_1 == "any" + + assert len(interface.ipv6.slaac) == 1 + assert interface.ipv6.slaac[0].range == "1234::5678/64" + assert interface.ipv6.slaac[0].address == "1234::5678" + + assert len(interface.ipv6.ranges) == 1 + assert interface.ipv6.ranges[0].range == "1234::5678/64" + + assert interface.ipv6.is_public + assert interface.ip_ranges == ["10.0.0.0/24"] def test_update(self): @@ -936,6 +1038,7 @@ def test_update(self): interface._api_get() interface.ipv4.vpc = "10.0.0.3" + interface.ipv6.is_public = False interface.primary = False interface.ip_ranges = ["10.0.0.2/32"] @@ -953,6 +1056,11 @@ def test_update(self): assert m.call_data == { "primary": False, "ipv4": {"vpc": "10.0.0.3", "nat_1_1": "any"}, + "ipv6": { + "slaac": [{"range": "1234::5678/64"}], + "ranges": [{"range": "1234::5678/64"}], + "is_public": False, + }, "ip_ranges": ["10.0.0.2/32"], } @@ -973,8 +1081,17 @@ def test_get_vpc(self): self.assertEqual(interface.purpose, "vpc") self.assertEqual(interface.vpc.id, 123456) self.assertEqual(interface.subnet.id, 789) + self.assertEqual(interface.ipv4.vpc, "10.0.0.2") self.assertEqual(interface.ipv4.nat_1_1, "any") + + self.assertEqual(len(interface.ipv6.slaac), 1) + self.assertEqual(interface.ipv6.slaac[0].range, "1234::5678/64") + self.assertEqual(interface.ipv6.slaac[0].address, "1234::5678") + self.assertEqual(len(interface.ipv6.ranges), 1) + self.assertEqual(interface.ipv6.ranges[0].range, "1234::5678/64") + self.assertEqual(interface.ipv6.is_public, True) + self.assertEqual(interface.ip_ranges, ["10.0.0.0/24"]) self.assertEqual(interface.active, True) diff --git a/test/unit/objects/vpc_test.py b/test/unit/objects/vpc_test.py index f69066aa5..90ec348da 100644 --- a/test/unit/objects/vpc_test.py +++ b/test/unit/objects/vpc_test.py @@ -113,6 +113,8 @@ def validate_vpc_123456(self, vpc: VPC): self.assertEqual(vpc.created, expected_dt) self.assertEqual(vpc.updated, expected_dt) + self.assertEqual(vpc.ipv6[0].range, "fd71:1140:a9d0::/52") + def validate_vpc_subnet_789(self, subnet: VPCSubnet): expected_dt = datetime.datetime.strptime( "2018-01-01T00:01:01", DATE_FORMAT @@ -136,6 +138,8 @@ def validate_vpc_subnet_789(self, subnet: VPCSubnet): assert not subnet.linodes[0].interfaces[1].active assert subnet.linodes[0].interfaces[1].config_id is None + self.assertEqual(subnet.ipv6[0].range, "fd71:1140:a9d0::/52") + def test_list_vpc_ips(self): """ Test that the ips under a specific VPC can be listed. @@ -160,3 +164,11 @@ def test_list_vpc_ips(self): self.assertEqual(vpc_ip.gateway, "10.0.0.1") self.assertEqual(vpc_ip.prefix, 8) self.assertEqual(vpc_ip.subnet_mask, "255.0.0.0") + + vpc_ip_2 = vpc_ips[2] + + self.assertEqual(vpc_ip_2.ipv6_range, "fd71:1140:a9d0::/52") + self.assertEqual(vpc_ip_2.ipv6_is_public, True) + self.assertEqual( + vpc_ip_2.ipv6_addresses[0].slaac_address, "fd71:1140:a9d0::/52" + ) From 4f74fae126ad2bb24282052a3e389069203d0263 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:30:16 -0400 Subject: [PATCH 342/379] Fix conflict oversight (#610) --- test/integration/models/linode/test_linode.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 1413e12d5..c485dd19c 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -721,15 +721,22 @@ def test_linode_interfaces_settings(linode_with_linode_interfaces): settings = linode.interfaces_settings assert settings.network_helper is not None - assert settings.default_route.ipv4_interface_id == linode.interfaces[0].id + assert ( + settings.default_route.ipv4_interface_id + == linode.linode_interfaces[0].id + ) assert settings.default_route.ipv4_eligible_interface_ids == [ - linode.interfaces[0].id, - linode.interfaces[1].id, + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, ] - assert settings.default_route.ipv6_interface_id == linode.interfaces[0].id + assert ( + settings.default_route.ipv6_interface_id + == linode.linode_interfaces[0].id + ) assert settings.default_route.ipv6_eligible_interface_ids == [ - linode.interfaces[0].id + linode.linode_interfaces[0].id, + linode.linode_interfaces[1].id, ] # Arbitrary updates From 34eed03c5830d1525a436e3d2b1811e06c9fb12d Mon Sep 17 00:00:00 2001 From: Pawel <100145168+PawelSnoch@users.noreply.github.com> Date: Fri, 10 Oct 2025 08:43:21 +0200 Subject: [PATCH 343/379] Add test get not supported service (#609) --- test/integration/models/monitor/test_monitor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index 7c9249f42..b458fd399 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -55,6 +55,13 @@ def test_get_supported_services(test_linode_client): assert isinstance(metric_definitions[0], MonitorMetricsDefinition) +def test_get_not_supported_service(test_linode_client): + client = test_linode_client + with pytest.raises(RuntimeError) as err: + client.load(MonitorService, "saas") + assert "[404] Not found" in str(err.value) + + # Test Helpers def get_db_engine_id(client: LinodeClient, engine: str): engines = client.database.engines() From be4afe83d14e5f159b947efd51f5e9a694f494f8 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:55:55 -0400 Subject: [PATCH 344/379] Handle explicit null value in `_flatten_request_body_recursive` (#612) * Accept explicit null value in `_flatten_request_body_recursive` * Add test * make format --- linode_api4/objects/base.py | 3 + test/unit/objects/base_test.py | 286 +++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 test/unit/objects/base_test.py diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 51a16eae0..9f2a55589 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -530,6 +530,9 @@ def _flatten_request_body_recursive(data: Any, is_put: bool = False) -> Any: if isinstance(data, Base): return data.id + if isinstance(data, ExplicitNullValue) or data == ExplicitNullValue: + return None + if isinstance(data, MappedObject) or issubclass(type(data), JSONObject): return data._serialize(is_put=is_put) diff --git a/test/unit/objects/base_test.py b/test/unit/objects/base_test.py new file mode 100644 index 000000000..d60a3bd38 --- /dev/null +++ b/test/unit/objects/base_test.py @@ -0,0 +1,286 @@ +from dataclasses import dataclass +from test.unit.base import ClientBaseCase + +from linode_api4.objects import Base, JSONObject, MappedObject, Property +from linode_api4.objects.base import ( + ExplicitNullValue, + _flatten_request_body_recursive, +) + + +class FlattenRequestBodyRecursiveCase(ClientBaseCase): + """Test cases for _flatten_request_body_recursive function""" + + def test_flatten_primitive_types(self): + """Test that primitive types are returned as-is""" + self.assertEqual(_flatten_request_body_recursive(123), 123) + self.assertEqual(_flatten_request_body_recursive("test"), "test") + self.assertEqual(_flatten_request_body_recursive(3.14), 3.14) + self.assertEqual(_flatten_request_body_recursive(True), True) + self.assertEqual(_flatten_request_body_recursive(False), False) + self.assertEqual(_flatten_request_body_recursive(None), None) + + def test_flatten_dict(self): + """Test that dicts are recursively flattened""" + test_dict = {"key1": "value1", "key2": 123, "key3": True} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, test_dict) + + def test_flatten_nested_dict(self): + """Test that nested dicts are recursively flattened""" + test_dict = { + "level1": { + "level2": {"level3": "value", "number": 42}, + "string": "test", + }, + "array": [1, 2, 3], + } + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, test_dict) + + def test_flatten_list(self): + """Test that lists are recursively flattened""" + test_list = [1, "two", 3.0, True] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, test_list) + + def test_flatten_nested_list(self): + """Test that nested lists are recursively flattened""" + test_list = [[1, 2], [3, [4, 5]], "string"] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, test_list) + + def test_flatten_base_object(self): + """Test that Base objects are flattened to their ID""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + } + + obj = TestBase(self.client, 123) + result = _flatten_request_body_recursive(obj) + self.assertEqual(result, 123) + + def test_flatten_base_object_in_dict(self): + """Test that Base objects in dicts are flattened to their ID""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + } + + obj = TestBase(self.client, 456) + test_dict = {"resource": obj, "name": "test"} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, {"resource": 456, "name": "test"}) + + def test_flatten_base_object_in_list(self): + """Test that Base objects in lists are flattened to their ID""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "label": Property(mutable=True), + } + + obj1 = TestBase(self.client, 111) + obj2 = TestBase(self.client, 222) + test_list = [obj1, "middle", obj2] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, [111, "middle", 222]) + + def test_flatten_explicit_null_instance(self): + """Test that ExplicitNullValue instances are converted to None""" + result = _flatten_request_body_recursive(ExplicitNullValue()) + self.assertIsNone(result) + + def test_flatten_explicit_null_class(self): + """Test that ExplicitNullValue class is converted to None""" + result = _flatten_request_body_recursive(ExplicitNullValue) + self.assertIsNone(result) + + def test_flatten_explicit_null_in_dict(self): + """Test that ExplicitNullValue in dicts is converted to None""" + test_dict = { + "field1": "value", + "field2": ExplicitNullValue(), + "field3": ExplicitNullValue, + } + result = _flatten_request_body_recursive(test_dict) + self.assertEqual( + result, {"field1": "value", "field2": None, "field3": None} + ) + + def test_flatten_explicit_null_in_list(self): + """Test that ExplicitNullValue in lists is converted to None""" + test_list = ["value", ExplicitNullValue(), ExplicitNullValue, 123] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, ["value", None, None, 123]) + + def test_flatten_mapped_object(self): + """Test that MappedObject is serialized""" + mapped_obj = MappedObject(key1="value1", key2=123) + result = _flatten_request_body_recursive(mapped_obj) + self.assertEqual(result, {"key1": "value1", "key2": 123}) + + def test_flatten_mapped_object_nested(self): + """Test that nested MappedObject is serialized""" + mapped_obj = MappedObject( + outer="value", inner={"nested_key": "nested_value"} + ) + result = _flatten_request_body_recursive(mapped_obj) + # The inner dict becomes a MappedObject when created + self.assertIn("outer", result) + self.assertEqual(result["outer"], "value") + self.assertIn("inner", result) + + def test_flatten_mapped_object_in_dict(self): + """Test that MappedObject in dicts is serialized""" + mapped_obj = MappedObject(key="value") + test_dict = {"field": mapped_obj, "other": "data"} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, {"field": {"key": "value"}, "other": "data"}) + + def test_flatten_mapped_object_in_list(self): + """Test that MappedObject in lists is serialized""" + mapped_obj = MappedObject(key="value") + test_list = [mapped_obj, "string", 123] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, [{"key": "value"}, "string", 123]) + + def test_flatten_json_object(self): + """Test that JSONObject subclasses are serialized""" + + @dataclass + class TestJSONObject(JSONObject): + field1: str = "" + field2: int = 0 + + json_obj = TestJSONObject.from_json({"field1": "test", "field2": 42}) + result = _flatten_request_body_recursive(json_obj) + self.assertEqual(result, {"field1": "test", "field2": 42}) + + def test_flatten_json_object_in_dict(self): + """Test that JSONObject in dicts is serialized""" + + @dataclass + class TestJSONObject(JSONObject): + name: str = "" + + json_obj = TestJSONObject.from_json({"name": "test"}) + test_dict = {"obj": json_obj, "value": 123} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, {"obj": {"name": "test"}, "value": 123}) + + def test_flatten_json_object_in_list(self): + """Test that JSONObject in lists is serialized""" + + @dataclass + class TestJSONObject(JSONObject): + id: int = 0 + + json_obj = TestJSONObject.from_json({"id": 999}) + test_list = [json_obj, "text"] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, [{"id": 999}, "text"]) + + def test_flatten_complex_nested_structure(self): + """Test a complex nested structure with multiple types""" + + class TestBase(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + } + + @dataclass + class TestJSONObject(JSONObject): + value: str = "" + + base_obj = TestBase(self.client, 555) + mapped_obj = MappedObject(key="mapped") + json_obj = TestJSONObject.from_json({"value": "json"}) + + complex_structure = { + "base": base_obj, + "mapped": mapped_obj, + "json": json_obj, + "null": ExplicitNullValue(), + "list": [base_obj, mapped_obj, json_obj, ExplicitNullValue], + "nested": { + "deep": { + "base": base_obj, + "primitives": [1, "two", 3.0], + } + }, + } + + result = _flatten_request_body_recursive(complex_structure) + + self.assertEqual(result["base"], 555) + self.assertEqual(result["mapped"], {"key": "mapped"}) + self.assertEqual(result["json"], {"value": "json"}) + self.assertIsNone(result["null"]) + self.assertEqual( + result["list"], [555, {"key": "mapped"}, {"value": "json"}, None] + ) + self.assertEqual(result["nested"]["deep"]["base"], 555) + self.assertEqual( + result["nested"]["deep"]["primitives"], [1, "two", 3.0] + ) + + def test_flatten_with_is_put_false(self): + """Test that is_put parameter is passed through""" + + @dataclass + class TestJSONObject(JSONObject): + field: str = "" + + def _serialize(self, is_put=False): + return {"field": self.field, "is_put": is_put} + + json_obj = TestJSONObject.from_json({"field": "test"}) + result = _flatten_request_body_recursive(json_obj, is_put=False) + self.assertEqual(result, {"field": "test", "is_put": False}) + + def test_flatten_with_is_put_true(self): + """Test that is_put=True parameter is passed through""" + + @dataclass + class TestJSONObject(JSONObject): + field: str = "" + + def _serialize(self, is_put=False): + return {"field": self.field, "is_put": is_put} + + json_obj = TestJSONObject.from_json({"field": "test"}) + result = _flatten_request_body_recursive(json_obj, is_put=True) + self.assertEqual(result, {"field": "test", "is_put": True}) + + def test_flatten_empty_dict(self): + """Test that empty dicts are handled correctly""" + result = _flatten_request_body_recursive({}) + self.assertEqual(result, {}) + + def test_flatten_empty_list(self): + """Test that empty lists are handled correctly""" + result = _flatten_request_body_recursive([]) + self.assertEqual(result, []) + + def test_flatten_dict_with_none_values(self): + """Test that None values in dicts are preserved""" + test_dict = {"key1": "value", "key2": None, "key3": 0} + result = _flatten_request_body_recursive(test_dict) + self.assertEqual(result, test_dict) + + def test_flatten_list_with_none_values(self): + """Test that None values in lists are preserved""" + test_list = ["value", None, 0, ""] + result = _flatten_request_body_recursive(test_list) + self.assertEqual(result, test_list) From 06b09b8234420b8bc3e1480e06cdf8c8c0a08998 Mon Sep 17 00:00:00 2001 From: rammanoj Date: Mon, 27 Oct 2025 15:29:24 -0400 Subject: [PATCH 345/379] Add firewall_id to LNP (#615) * add firewall_id * Add to integration tests --------- Co-authored-by: Erik Zilber Co-authored-by: rpotla Co-authored-by: Lena Garber --- linode_api4/objects/lke.py | 1 + test/fixtures/lke_clusters_18881_pools_456.json | 1 + test/fixtures/lke_clusters_18882_pools_789.json | 1 + test/integration/models/lke/test_lke.py | 10 ++++++++-- test/unit/objects/lke_test.py | 4 ++++ 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 792aed988..0864052f1 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -205,6 +205,7 @@ class LKENodePool(DerivedBase): # directly exposed in the node pool response. "k8s_version": Property(mutable=True), "update_strategy": Property(mutable=True), + "firewall_id": Property(mutable=True), } def _parse_raw_node( diff --git a/test/fixtures/lke_clusters_18881_pools_456.json b/test/fixtures/lke_clusters_18881_pools_456.json index 9aa5fb0f0..7bf68a6f8 100644 --- a/test/fixtures/lke_clusters_18881_pools_456.json +++ b/test/fixtures/lke_clusters_18881_pools_456.json @@ -35,6 +35,7 @@ "bar": "foo" }, "label": "example-node-pool", + "firewall_id": 456, "type": "g6-standard-4", "disk_encryption": "enabled" } \ No newline at end of file diff --git a/test/fixtures/lke_clusters_18882_pools_789.json b/test/fixtures/lke_clusters_18882_pools_789.json index d3c17eedb..8a5ba21d8 100644 --- a/test/fixtures/lke_clusters_18882_pools_789.json +++ b/test/fixtures/lke_clusters_18882_pools_789.json @@ -15,5 +15,6 @@ "tags": [], "disk_encryption": "enabled", "k8s_version": "1.31.1+lke1", + "firewall_id": 789, "update_strategy": "rolling_update" } \ No newline at end of file diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 241117442..71ebc1ff2 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -138,7 +138,7 @@ def lke_cluster_with_apl(test_linode_client): @pytest.fixture(scope="session") -def lke_cluster_enterprise(test_linode_client): +def lke_cluster_enterprise(e2e_test_firewall, test_linode_client): # We use the oldest version here so we can test upgrades version = sorted( v.id for v in test_linode_client.lke.tier("enterprise").versions() @@ -153,6 +153,7 @@ def lke_cluster_enterprise(test_linode_client): 3, k8s_version=version, update_strategy="rolling_update", + firewall_id=e2e_test_firewall.id, ) label = get_test_label() + "_cluster" @@ -434,13 +435,18 @@ def test_lke_cluster_with_apl(lke_cluster_with_apl): ) -def test_lke_cluster_enterprise(test_linode_client, lke_cluster_enterprise): +def test_lke_cluster_enterprise( + e2e_test_firewall, + test_linode_client, + lke_cluster_enterprise, +): lke_cluster_enterprise.invalidate() assert lke_cluster_enterprise.tier == "enterprise" pool = lke_cluster_enterprise.pools[0] assert str(pool.k8s_version) == lke_cluster_enterprise.k8s_version.id assert pool.update_strategy == "rolling_update" + assert pool.firewall_id == e2e_test_firewall.id target_version = sorted( v.id for v in test_linode_client.lke.tier("enterprise").versions() diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index cb9589cfb..10284a0c9 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -52,6 +52,7 @@ def test_get_pool(self): assert pool.cluster_id == 18881 assert pool.type.id == "g6-standard-4" assert pool.label == "example-node-pool" + assert pool.firewall_id == 456 assert pool.disk_encryption == InstanceDiskEncryptionType.enabled assert pool.disks is not None @@ -254,6 +255,7 @@ def test_lke_node_pool_update(self): pool.tags = ["foobar"] pool.count = 5 pool.label = "testing-label" + pool.firewall_id = 852 pool.autoscaler = { "enabled": True, "min": 2, @@ -281,6 +283,7 @@ def test_lke_node_pool_update(self): "labels": { "updated-key": "updated-value", }, + "firewall_id": 852, "taints": [ { "key": "updated-key", @@ -551,6 +554,7 @@ def test_cluster_enterprise(self): assert pool.k8s_version == "1.31.1+lke1" assert pool.update_strategy == "rolling_update" assert pool.label == "enterprise-node-pool" + assert pool.firewall_id == 789 def test_lke_tiered_version(self): version = TieredKubeVersion(self.client, "1.32", "standard") From 86c26dff6f96df7ee918bafd136c9b74d4de8c17 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:38:24 -0400 Subject: [PATCH 346/379] build(deps): bump actions/upload-artifact from 4 to 5 (#618) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 282914ebc..bde464c23 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -97,7 +97,7 @@ jobs: - name: Upload Test Report as Artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: test-report-file if-no-files-found: ignore From e2652d8ead551d42db6a9e5e4a2b821316fda6a7 Mon Sep 17 00:00:00 2001 From: pmajali Date: Thu, 13 Nov 2025 20:33:58 +0530 Subject: [PATCH 347/379] adding monitors in region endpoint (#587) * adding monitors in region endpoint * updating with review comments * fixing build issues * fixing build issues * fixing build issue * fixing build issue * fixing lint * fixing filters * adding int-test for filter and groupby --- linode_api4/objects/monitor.py | 51 +++++++++++++++++-- linode_api4/objects/region.py | 13 +++++ test/fixtures/monitor_dashboards.json | 8 ++- test/fixtures/monitor_dashboards_1.json | 8 ++- .../monitor_services_dbaas_dashboards.json | 15 +++++- test/fixtures/regions.json | 8 +++ .../models/monitor/test_monitor.py | 51 +++++++++++++++++++ test/unit/objects/monitor_test.py | 27 +++++++++- test/unit/objects/region_test.py | 5 ++ 9 files changed, 175 insertions(+), 11 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ed6ce79a5..fb339a0fd 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -49,6 +49,7 @@ class ServiceType(StrEnum): firewall = "firewall" object_storage = "object_storage" aclb = "aclb" + net_load_balancer = "netloadbalancer" class MetricType(StrEnum): @@ -82,6 +83,10 @@ class MetricUnit(StrEnum): RATIO = "ratio" OPS_PER_SECOND = "ops_per_second" IOPS = "iops" + KILO_BYTES_PER_SECOND = "kilo_bytes_per_second" + SESSIONS_PER_SECOND = "sessions_per_second" + PACKETS_PER_SECOND = "packets_per_second" + KILO_BITS_PER_SECOND = "kilo_bits_per_second" class DashboardType(StrEnum): @@ -93,6 +98,17 @@ class DashboardType(StrEnum): custom = "custom" +@dataclass +class Filter(JSONObject): + """ + Represents a filter in the filters list of a dashboard widget. + """ + + dimension_label: str = "" + operator: str = "" + value: str = "" + + @dataclass class DashboardWidget(JSONObject): """ @@ -107,6 +123,34 @@ class DashboardWidget(JSONObject): chart_type: ChartType = "" y_label: str = "" aggregate_function: AggregateFunction = "" + group_by: Optional[List[str]] = None + _filters: Optional[List[Filter]] = field( + default=None, metadata={"json_key": "filters"} + ) + + def __getattribute__(self, name): + """Override to handle the filters attribute specifically to avoid metaclass conflict.""" + if name == "filters": + return object.__getattribute__(self, "_filters") + return object.__getattribute__(self, name) + + def __setattr__(self, name, value): + """Override to handle setting the filters attribute.""" + if name == "filters": + object.__setattr__(self, "_filters", value) + else: + object.__setattr__(self, name, value) + + +@dataclass +class ServiceAlert(JSONObject): + """ + Represents alert configuration options for a monitor service. + """ + + polling_interval_seconds: Optional[List[int]] = None + evaluation_period_seconds: Optional[List[int]] = None + scope: Optional[List[str]] = None @dataclass @@ -135,9 +179,7 @@ class MonitorMetricsDefinition(JSONObject): scrape_interval: int = 0 is_alertable: bool = False dimensions: Optional[List[Dimension]] = None - available_aggregate_functions: List[AggregateFunction] = field( - default_factory=list - ) + available_aggregate_functions: Optional[List[AggregateFunction]] = None class MonitorDashboard(Base): @@ -154,7 +196,7 @@ class MonitorDashboard(Base): "label": Property(), "service_type": Property(ServiceType), "type": Property(DashboardType), - "widgets": Property(List[DashboardWidget]), + "widgets": Property(json_object=DashboardWidget), "updated": Property(is_datetime=True), } @@ -171,6 +213,7 @@ class MonitorService(Base): properties = { "service_type": Property(ServiceType), "label": Property(), + "alert": Property(json_object=ServiceAlert), } diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 34577c336..c9dc05099 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -16,6 +16,18 @@ class RegionPlacementGroupLimits(JSONObject): maximum_linodes_per_pg: int = 0 +@dataclass +class RegionMonitors(JSONObject): + """ + Represents the monitor services available in a region. + Lists the services in this region that support metrics and alerts + use with Akamai Cloud Pulse (ACLP). + """ + + alerts: Optional[list[str]] = None + metrics: Optional[list[str]] = None + + class Region(Base): """ A Region. Regions correspond to individual data centers, each located in a different geographical area. @@ -35,6 +47,7 @@ class Region(Base): "placement_group_limits": Property( json_object=RegionPlacementGroupLimits ), + "monitors": Property(json_object=RegionMonitors), } @property diff --git a/test/fixtures/monitor_dashboards.json b/test/fixtures/monitor_dashboards.json index 42de92b55..5e56923a1 100644 --- a/test/fixtures/monitor_dashboards.json +++ b/test/fixtures/monitor_dashboards.json @@ -16,7 +16,9 @@ "metric": "cpu_usage", "size": 12, "unit": "%", - "y_label": "cpu_usage" + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null }, { "aggregate_function": "sum", @@ -26,7 +28,9 @@ "metric": "write_iops", "size": 6, "unit": "IOPS", - "y_label": "write_iops" + "y_label": "write_iops", + "group_by": ["entity_id"], + "filters": null } ] } diff --git a/test/fixtures/monitor_dashboards_1.json b/test/fixtures/monitor_dashboards_1.json index b78bf3447..afb5d71ee 100644 --- a/test/fixtures/monitor_dashboards_1.json +++ b/test/fixtures/monitor_dashboards_1.json @@ -14,7 +14,9 @@ "metric": "cpu_usage", "size": 12, "unit": "%", - "y_label": "cpu_usage" + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null }, { "aggregate_function": "sum", @@ -24,7 +26,9 @@ "metric": "available_memory", "size": 6, "unit": "GB", - "y_label": "available_memory" + "y_label": "available_memory", + "group_by": ["entity_id"], + "filters": null } ] } \ No newline at end of file diff --git a/test/fixtures/monitor_services_dbaas_dashboards.json b/test/fixtures/monitor_services_dbaas_dashboards.json index 5fbb7e9db..e39a231b2 100644 --- a/test/fixtures/monitor_services_dbaas_dashboards.json +++ b/test/fixtures/monitor_services_dbaas_dashboards.json @@ -16,7 +16,9 @@ "metric": "cpu_usage", "size": 12, "unit": "%", - "y_label": "cpu_usage" + "y_label": "cpu_usage", + "group_by": ["entity_id"], + "filters": null }, { "aggregate_function": "sum", @@ -26,7 +28,16 @@ "metric": "memory_usage", "size": 6, "unit": "%", - "y_label": "memory_usage" + "y_label": "memory_usage", + "group_by": ["entity_id"], + "filters": [ + { + "dimension_label": "pattern", + "operator": "in", + "value": "publicout,privateout" + } + ] + } ] } diff --git a/test/fixtures/regions.json b/test/fixtures/regions.json index b58db045d..1482def37 100644 --- a/test/fixtures/regions.json +++ b/test/fixtures/regions.json @@ -132,6 +132,14 @@ "Object Storage", "Linode Interfaces" ], + "monitors": { + "alerts": [ + "Managed Databases" + ], + "metrics": [ + "Managed Databases" + ] + }, "status": "ok", "resolvers": { "ipv4": "66.228.42.5,96.126.106.5,50.116.53.5,50.116.58.5,50.116.61.5,50.116.62.5,66.175.211.5,97.107.133.4,207.192.69.4,207.192.69.5", diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index b458fd399..eed85ab14 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -35,6 +35,57 @@ def test_get_all_dashboards(test_linode_client): assert dashboards_by_svc[0].service_type == get_service_type +def test_filter_and_group_by(test_linode_client): + client = test_linode_client + dashboards_by_svc = client.monitor.dashboards(service_type="linode") + assert isinstance(dashboards_by_svc[0], MonitorDashboard) + + # Get the first dashboard for linode service type + dashboard = dashboards_by_svc[0] + assert dashboard.service_type == "linode" + + # Ensure the dashboard has widgets + assert hasattr( + dashboard, "widgets" + ), "Dashboard should have widgets attribute" + assert dashboard.widgets is not None, "Dashboard widgets should not be None" + assert ( + len(dashboard.widgets) > 0 + ), "Dashboard should have at least one widget" + + # Test the first widget's group_by and filters fields + widget = dashboard.widgets[0] + + # Test group_by field type + group_by = widget.group_by + assert group_by is None or isinstance( + group_by, list + ), "group_by should be None or list type" + if group_by is not None: + for item in group_by: + assert isinstance(item, str), "group_by items should be strings" + + # Test filters field type + filters = widget.filters + assert filters is None or isinstance( + filters, list + ), "filters should be None or list type" + if filters is not None: + from linode_api4.objects.monitor import Filter + + for filter_item in filters: + assert isinstance( + filter_item, Filter + ), "filter items should be Filter objects" + assert hasattr( + filter_item, "dimension_label" + ), "Filter should have dimension_label" + assert hasattr( + filter_item, "operator" + ), "Filter should have operator" + assert hasattr(filter_item, "value"), "Filter should have value" + + # List supported services def test_get_supported_services(test_linode_client): client = test_linode_client diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index a010514c2..329a09063 100644 --- a/test/unit/objects/monitor_test.py +++ b/test/unit/objects/monitor_test.py @@ -41,6 +41,8 @@ def test_dashboard_by_ID(self): self.assertEqual(dashboard.widgets[0].size, 12) self.assertEqual(dashboard.widgets[0].unit, "%") self.assertEqual(dashboard.widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboard.widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboard.widgets[0].filters) def test_dashboard_by_service_type(self): dashboards = self.client.monitor.dashboards(service_type="dbaas") @@ -62,6 +64,21 @@ def test_dashboard_by_service_type(self): self.assertEqual(dashboards[0].widgets[0].size, 12) self.assertEqual(dashboards[0].widgets[0].unit, "%") self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].filters) + + # Test the second widget which has filters + self.assertEqual(dashboards[0].widgets[1].label, "Memory Usage") + self.assertEqual(dashboards[0].widgets[1].group_by, ["entity_id"]) + self.assertIsNotNone(dashboards[0].widgets[1].filters) + self.assertEqual(len(dashboards[0].widgets[1].filters), 1) + self.assertEqual( + dashboards[0].widgets[1].filters[0].dimension_label, "pattern" + ) + self.assertEqual(dashboards[0].widgets[1].filters[0].operator, "in") + self.assertEqual( + dashboards[0].widgets[1].filters[0].value, "publicout,privateout" + ) def test_get_all_dashboards(self): dashboards = self.client.monitor.dashboards() @@ -83,12 +100,20 @@ def test_get_all_dashboards(self): self.assertEqual(dashboards[0].widgets[0].size, 12) self.assertEqual(dashboards[0].widgets[0].unit, "%") self.assertEqual(dashboards[0].widgets[0].y_label, "cpu_usage") + self.assertEqual(dashboards[0].widgets[0].group_by, ["entity_id"]) + self.assertIsNone(dashboards[0].widgets[0].filters) def test_specific_service_details(self): data = self.client.load(MonitorService, "dbaas") self.assertEqual(data.label, "Databases") self.assertEqual(data.service_type, "dbaas") + # Test alert configuration + self.assertIsNotNone(data.alert) + self.assertEqual(data.alert.polling_interval_seconds, [300]) + self.assertEqual(data.alert.evaluation_period_seconds, [300]) + self.assertEqual(data.alert.scope, ["entity"]) + def test_metric_definitions(self): metrics = self.client.monitor.metric_definitions(service_type="dbaas") @@ -96,7 +121,7 @@ def test_metric_definitions(self): metrics[0].available_aggregate_functions, ["max", "avg", "min", "sum"], ) - self.assertEqual(metrics[0].is_alertable, True) + self.assertTrue(metrics[0].is_alertable) self.assertEqual(metrics[0].label, "CPU Usage") self.assertEqual(metrics[0].metric, "cpu_usage") self.assertEqual(metrics[0].metric_type, "gauge") diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 6ae503098..73fdc8f5d 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -27,6 +27,11 @@ def test_get_region(self): region.placement_group_limits.maximum_linodes_per_pg, 5 ) + # Test monitors section + self.assertIsNotNone(region.monitors) + self.assertEqual(region.monitors.alerts, ["Managed Databases"]) + self.assertEqual(region.monitors.metrics, ["Managed Databases"]) + self.assertIsNotNone(region.capabilities) self.assertIn("Linode Interfaces", region.capabilities) From 5d395d499f05675c85e253a6d382156c4de27e59 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:02:14 -0500 Subject: [PATCH 348/379] Add alias support to Property class and related tests (#619) * Add alias support to Property class and related tests - Introduced `alias_of` parameter in Property to allow aliasing of API attributes. - Implemented `properties_with_alias` method in Base class to retrieve aliased properties. - Updated BetaProgram to include an aliased property for "class". - Added comprehensive tests for alias functionality in PropertyAliasTest. * Update linode_api4/objects/base.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Pre-compute keys * More readable condition * make format --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/objects/base.py | 158 ++++++++++++------- linode_api4/objects/beta.py | 1 + test/unit/objects/property_alias_test.py | 191 +++++++++++++++++++++++ 3 files changed, 290 insertions(+), 60 deletions(-) create mode 100644 test/unit/objects/property_alias_test.py diff --git a/linode_api4/objects/base.py b/linode_api4/objects/base.py index 9f2a55589..78e53fd45 100644 --- a/linode_api4/objects/base.py +++ b/linode_api4/objects/base.py @@ -1,5 +1,6 @@ import time from datetime import datetime, timedelta +from functools import cached_property from typing import Any, Dict, Optional from linode_api4.objects.serializable import JSONObject @@ -35,27 +36,43 @@ def __init__( nullable=False, unordered=False, json_object=None, + alias_of: Optional[str] = None, ): """ A Property is an attribute returned from the API, and defines metadata - about that value. These are expected to be used as the values of a + about that value. These are expected to be used as the values of a class-level dict named 'properties' in subclasses of Base. - mutable - This Property should be sent in a call to save() - identifier - This Property identifies the object in the API - volatile - Re-query for this Property if the local value is older than the - volatile refresh timeout - relationship - The API Object this Property represents - derived_class - The sub-collection type this Property represents - is_datetime - True if this Property should be parsed as a datetime.datetime - id_relationship - This Property should create a relationship with this key as the ID - (This should be used on fields ending with '_id' only) - slug_relationship - This property is a slug related for a given type. - nullable - This property can be explicitly null on PUT requests. - unordered - The order of this property is not significant. - NOTE: This field is currently only for annotations purposes - and does not influence any update or decoding/encoding logic. - json_object - The JSONObject class this property should be decoded into. + :param mutable: This Property should be sent in a call to save() + :type mutable: bool + :param identifier: This Property identifies the object in the API + :type identifier: bool + :param volatile: Re-query for this Property if the local value is older than the + volatile refresh timeout + :type volatile: bool + :param relationship: The API Object this Property represents + :type relationship: type or None + :param derived_class: The sub-collection type this Property represents + :type derived_class: type or None + :param is_datetime: True if this Property should be parsed as a datetime.datetime + :type is_datetime: bool + :param id_relationship: This Property should create a relationship with this key as the ID + (This should be used on fields ending with '_id' only) + :type id_relationship: type or None + :param slug_relationship: This property is a slug related for a given type + :type slug_relationship: type or None + :param nullable: This property can be explicitly null on PUT requests + :type nullable: bool + :param unordered: The order of this property is not significant. + NOTE: This field is currently only for annotations purposes + and does not influence any update or decoding/encoding logic. + :type unordered: bool + :param json_object: The JSONObject class this property should be decoded into + :type json_object: type or None + :param alias_of: The original API attribute name when the property key is aliased. + This is useful when the API attribute name is a Python reserved word, + allowing you to use a different key while preserving the original name. + :type alias_of: str or None """ self.mutable = mutable self.identifier = identifier @@ -68,6 +85,7 @@ def __init__( self.nullable = nullable self.unordered = unordered self.json_class = json_object + self.alias_of = alias_of class MappedObject: @@ -252,6 +270,21 @@ def __setattr__(self, name, value): self._set(name, value) + @cached_property + def properties_with_alias(self) -> dict[str, tuple[str, Property]]: + """ + Gets a dictionary of aliased properties for this object. + + :returns: A dict mapping original API attribute names to their alias names and + corresponding Property instances. + :rtype: dict[str, tuple[str, Property]] + """ + return { + prop.alias_of: (alias, prop) + for alias, prop in type(self).properties.items() + if prop.alias_of + } + def save(self, force=True) -> bool: """ Send this object's mutable values to the server in a PUT request. @@ -345,7 +378,8 @@ def _serialize(self, is_put: bool = False): ): value = None - result[k] = value + api_key = k if not v.alias_of else v.alias_of + result[api_key] = value # Resolve the underlying IDs of results for k, v in result.items(): @@ -373,55 +407,55 @@ def _populate(self, json): self._set("_raw_json", json) self._set("_updated", False) - for key in json: - if key in ( - k - for k in type(self).properties.keys() - if not type(self).properties[k].identifier - ): - if ( - type(self).properties[key].relationship - and not json[key] is None - ): - if isinstance(json[key], list): + valid_keys = set( + k + for k, v in type(self).properties.items() + if (not v.identifier) and (not v.alias_of) + ) | set(self.properties_with_alias.keys()) + + for api_key in json: + if api_key in valid_keys: + prop = type(self).properties.get(api_key) + prop_key = api_key + + if prop is None: + prop_key, prop = self.properties_with_alias[api_key] + + if prop.relationship and json[api_key] is not None: + if isinstance(json[api_key], list): objs = [] - for d in json[key]: + for d in json[api_key]: if not "id" in d: continue - new_class = type(self).properties[key].relationship + new_class = prop.relationship obj = new_class.make_instance( d["id"], getattr(self, "_client") ) if obj: obj._populate(d) objs.append(obj) - self._set(key, objs) + self._set(prop_key, objs) else: - if isinstance(json[key], dict): - related_id = json[key]["id"] + if isinstance(json[api_key], dict): + related_id = json[api_key]["id"] else: - related_id = json[key] - new_class = type(self).properties[key].relationship + related_id = json[api_key] + new_class = prop.relationship obj = new_class.make_instance( related_id, getattr(self, "_client") ) - if obj and isinstance(json[key], dict): - obj._populate(json[key]) - self._set(key, obj) - elif ( - type(self).properties[key].slug_relationship - and not json[key] is None - ): + if obj and isinstance(json[api_key], dict): + obj._populate(json[api_key]) + self._set(prop_key, obj) + elif prop.slug_relationship and json[api_key] is not None: # create an object of the expected type with the given slug self._set( - key, - type(self) - .properties[key] - .slug_relationship(self._client, json[key]), + prop_key, + prop.slug_relationship(self._client, json[api_key]), ) - elif type(self).properties[key].json_class: - json_class = type(self).properties[key].json_class - json_value = json[key] + elif prop.json_class: + json_class = prop.json_class + json_value = json[api_key] # build JSON object if isinstance(json_value, list): @@ -430,25 +464,29 @@ def _populate(self, json): else: value = json_class.from_json(json_value) - self._set(key, value) - elif type(json[key]) is dict: - self._set(key, MappedObject(**json[key])) - elif type(json[key]) is list: + self._set(prop_key, value) + elif type(json[api_key]) is dict: + self._set(prop_key, MappedObject(**json[api_key])) + elif type(json[api_key]) is list: # we're going to use MappedObject's behavior with lists to # expand these, then grab the resulting value to set - mapping = MappedObject(_list=json[key]) - self._set(key, mapping._list) # pylint: disable=no-member - elif type(self).properties[key].is_datetime: + mapping = MappedObject(_list=json[api_key]) + self._set( + prop_key, mapping._list + ) # pylint: disable=no-member + elif prop.is_datetime: try: - t = time.strptime(json[key], DATE_FORMAT) - self._set(key, datetime.fromtimestamp(time.mktime(t))) + t = time.strptime(json[api_key], DATE_FORMAT) + self._set( + prop_key, datetime.fromtimestamp(time.mktime(t)) + ) except: # if this came back, there's probably an issue with the # python library; a field was marked as a datetime but # wasn't in the expected format. - self._set(key, json[key]) + self._set(prop_key, json[api_key]) else: - self._set(key, json[key]) + self._set(prop_key, json[api_key]) self._set("_populated", True) self._set("_last_updated", datetime.now()) diff --git a/linode_api4/objects/beta.py b/linode_api4/objects/beta.py index c957aa584..45d5c5102 100644 --- a/linode_api4/objects/beta.py +++ b/linode_api4/objects/beta.py @@ -19,4 +19,5 @@ class BetaProgram(Base): "ended": Property(is_datetime=True), "greenlight_only": Property(), "more_info": Property(), + "beta_class": Property(alias_of="class"), } diff --git a/test/unit/objects/property_alias_test.py b/test/unit/objects/property_alias_test.py new file mode 100644 index 000000000..09efa0e7e --- /dev/null +++ b/test/unit/objects/property_alias_test.py @@ -0,0 +1,191 @@ +""" +Tests for Property alias_of functionality +""" + +from test.unit.base import ClientBaseCase + +from linode_api4.objects import Base, Property + + +class PropertyAliasTest(ClientBaseCase): + """Test cases for Property alias_of parameter""" + + def test_alias_populate_from_json(self): + """Test that aliased properties are populated correctly from JSON""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be set using the Python-friendly name + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + + def test_alias_serialize(self): + """Test that aliased properties serialize back to original API names""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + obj._set("service_class", "premium") + obj._set("label", "test-label") + obj._set("_populated", True) + + result = obj._serialize() + + # The serialized output should use the original API attribute name + self.assertIn("class", result) + self.assertEqual(result["class"], "premium") + self.assertEqual(result["label"], "test-label") + # Should not contain the aliased name + self.assertNotIn("service_class", result) + + def test_properties_with_alias(self): + """Test that properties_with_alias returns correct mapping""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(alias_of="type"), + "label": Property(mutable=True), + } + + obj = TestModel(self.client, 123) + + alias_map = obj.properties_with_alias + + # Should contain mappings for aliased properties + self.assertIn("class", alias_map) + self.assertIn("type", alias_map) + + # Should map to tuples of (alias_name, Property) + alias_name, prop = alias_map["class"] + self.assertEqual(alias_name, "service_class") + self.assertEqual(prop.alias_of, "class") + + alias_name, prop = alias_map["type"] + self.assertEqual(alias_name, "beta_type") + self.assertEqual(prop.alias_of, "type") + + # Non-aliased properties should not be in the map + self.assertNotIn("label", alias_map) + self.assertNotIn("id", alias_map) + + def test_alias_no_conflict_with_regular_properties(self): + """Test that aliased properties don't conflict with regular properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "label": Property(mutable=True), + "status": Property(), + } + + json_data = { + "id": 123, + "class": "premium", + "label": "test-label", + "status": "active", + } + + obj = TestModel(self.client, 123, json_data) + + # All properties should be set correctly + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.label, "test-label") + self.assertEqual(obj.status, "active") + + def test_multiple_aliases(self): + """Test handling multiple aliased properties""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + "beta_type": Property(mutable=True, alias_of="type"), + "import_data": Property(mutable=True, alias_of="import"), + } + + json_data = { + "id": 123, + "class": "premium", + "type": "beta", + "import": "data", + } + + obj = TestModel(self.client, 123, json_data) + + # All aliased properties should be populated + self.assertEqual(obj.service_class, "premium") + self.assertEqual(obj.beta_type, "beta") + self.assertEqual(obj.import_data, "data") + + # Serialization should use original names + obj._set("_populated", True) + result = obj._serialize() + + self.assertEqual(result["class"], "premium") + self.assertEqual(result["type"], "beta") + self.assertEqual(result["import"], "data") + + def test_alias_with_none_value(self): + """Test that aliased properties handle None values correctly""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(mutable=True, alias_of="class"), + } + + json_data = { + "id": 123, + "class": None, + } + + obj = TestModel(self.client, 123, json_data) + + # The aliased property should be None + self.assertIsNone(obj.service_class) + + def test_alias_cached_property(self): + """Test that properties_with_alias is cached""" + + class TestModel(Base): + api_endpoint = "/test/{id}" + properties = { + "id": Property(identifier=True), + "service_class": Property(alias_of="class"), + } + + obj = TestModel(self.client, 123) + + # Access the cached property twice + result1 = obj.properties_with_alias + result2 = obj.properties_with_alias + + # Should return the same object (cached) + self.assertIs(result1, result2) From dc3164c10357c69c56131ff9be0d2b3966ccf880 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 16:11:15 -0500 Subject: [PATCH 349/379] build(deps): bump actions/checkout from 5 to 6 (#620) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- .github/workflows/codeql.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/e2e-test-pr.yml | 4 ++-- .github/workflows/e2e-test.yml | 8 ++++---- .github/workflows/labeler.yml | 2 +- .github/workflows/nightly-smoke-tests.yml | 2 +- .github/workflows/publish-pypi.yaml | 2 +- .github/workflows/release-cross-repo-test.yml | 4 ++-- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c665358d7..dd8eeea17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: setup python 3 uses: actions/setup-python@v6 @@ -33,7 +33,7 @@ jobs: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 527950d61..d3fa1315f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: build-mode: none steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index e31dcc975..ffba32062 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout repository' - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 with: diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index e5973ebbe..86809d177 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -48,7 +48,7 @@ jobs: # Check out merge commit - name: Checkout PR - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ inputs.sha }} fetch-depth: 0 @@ -150,7 +150,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index bde464c23..2e62e6bd5 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -57,7 +57,7 @@ jobs: steps: - name: Clone Repository with SHA if: ${{ inputs.sha != '' }} - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -65,7 +65,7 @@ jobs: - name: Clone Repository without SHA if: ${{ inputs.sha == '' }} - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -111,7 +111,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -178,7 +178,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 7a3ee5f37..843a41c4d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 diff --git a/.github/workflows/nightly-smoke-tests.yml b/.github/workflows/nightly-smoke-tests.yml index d905a1265..644ea9ce4 100644 --- a/.github/workflows/nightly-smoke-tests.yml +++ b/.github/workflows/nightly-smoke-tests.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: dev diff --git a/.github/workflows/publish-pypi.yaml b/.github/workflows/publish-pypi.yaml index 87a002bbe..a791be4c9 100644 --- a/.github/workflows/publish-pypi.yaml +++ b/.github/workflows/publish-pypi.yaml @@ -12,7 +12,7 @@ jobs: environment: pypi-release steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/release-cross-repo-test.yml b/.github/workflows/release-cross-repo-test.yml index 62f4bea47..69bf8031f 100644 --- a/.github/workflows/release-cross-repo-test.yml +++ b/.github/workflows/release-cross-repo-test.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout linode_api4 repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 submodules: 'recursive' @@ -30,7 +30,7 @@ jobs: python-version: '3.10' - name: Checkout ansible repo - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: repository: linode/ansible_linode path: .ansible/collections/ansible_collections/linode/cloud From 7faf88771bd21fefbbe9bc75c97f2276e093a32a Mon Sep 17 00:00:00 2001 From: srbhaakamai Date: Wed, 3 Dec 2025 03:06:35 +0530 Subject: [PATCH 350/379] Python SDK for ACLP Alerts (#589) * DI-26927 Python SDK code for GET Alert Definitions * DI-26927 Python SDK code modified for client code * DI-26927 Added Unit test cases and missing classes * DI-26927 made corrections to keep code consistent * DI-26927 Updated Unit and Integratoion Tests * [dev 51950af] DI-26927 Updated Unit and Integratoion Tests * Revert "DI-26927 Updated Unit and Integratoion Tests" This reverts commit 51950aff939f919024df5176b6cf5dcefb0164e6. * Revert "[dev 51950af] DI-26927 Updated Unit and Integratoion Tests" This reverts commit 6ca6a5ab0111fff988732096513c376388957c85. * DI-26927 Updated Unit and Integratoion Tests * Remove .venv from repo and add to .gitignore * DI-26927 reverted git ignore * DI-26927 reverted conftest,py * DI-26927 added accidentlly deleted file * DI-26927 Corrected Integration and Unit Test cases for Alerting APIs * DI-26927 Reverted conftest.py and check integration without those changes * DI-26927 fixed integration test for firewall and added time for alert update before deletion * DI-26927 fixed changing monitor.py as per review comments * DI-26927 fixed changing monitor.py as per review comments * DI-26927 fixed changing monitor.py as per review comments * DI-26927 Intermediate change to address internal review comments * DI-26927 CLosed review comments * DI-26927 CLosed review comments * DI-27156 closed review comments from Ketan * DI-27156 closed review comments from Ketan * DI-27156 fixed unit test cases post review comments fixes * DI-27156 Updated docstring and make unit test cases changes * DI-27156 Added unit test cases post review comments fixes * DI-27156 Added unit test cases post review comments fixes * tests(monitor): add MonitorAlertDefinitionsTest and update fixtures for alert-definitions * DI-27156 Added unit test cases post review comments fixes * DI-27156 Updated docstring * DI-27156 Updated unit test errors * removed unwanted files * added missing doxcstring * added missing doxcstring * added missing doxcstring * rmoved test files * closed final review comments * fixing integration test issues * Corrected Integration Test Case * DI-26927 Corrected json to object modifications issues * DI-26927 Corrected json to object modifications issues * DI-26927 Unit test corrections for mofied functions name * Delete test/fixtures/monitor/services/dbaas/alert-definitions.json Not Required * Delete test.py Not Required here required for local testing only * Delete test/fixtures/monitor/alert-definitions.json Not required here * DI-26927 reverted fixtures.py as its not necesary to change * DI-26927 reverted fixtures.py as its not necesary to change * DI-26927 reverted fixtures.py as its not necesary to change * DI-26927 fixed linting error * DI-26927 fixed linting error * DI-26927 fixed lint error caught in CI * Update test/unit/groups/monitor_api_test.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * DI-26927 fixed copilot comments * fixed review comments from APIv4 team * fixed review comments from APIv4 team * fixed review comments from APIv4 team * fixed review comments from APIv4 team * DI-26927 fixed review comments from API v4 team * fixed review comments from APIv4 team * fixed lint errors * Update linode_api4/groups/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/groups/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * fixed review errors * fixed unittest * fixed unittest * fixed unittest * fixed unittest * fixed unittest * fixed unittest * added update use case to integration test * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * fixed further review comments * Updated integration test * Updated integration test with more assert statements * Fix Linode interfaces property (#604) * Fix test for interfaces (#605) * Fix test * changed as per github-advanced-security comments * fixed formatting errors using python black * Migrate test fixtures discovery to be with pathlib (#599) * Migrate test fixtures discovery to be with pathlib * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/objects/monitor.py Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> * Fixed Copilot comments on unit and integration tests * corrected unit test assert statement * Added comment as per copilot suggestion for save() and delete() * resolved comments from copilot commented whereever not applicable * Test: verify automatic GPG signing is working * Incorporated copilot comments * Removed serialisabel file from commit * Fix Lint errors * fixed review comments fo mutable * fixed review comments to keep it in line with SDK guidelines * Addressed review comments on AlertDefinition class * Comprehensive monitor API improvements and code quality fixes - Removed unused imports and optimized import order - Updated test cases for monitor integration - Enhanced monitor objects with proper type annotations - Improved monitor groups with better error handling - Applied code review feedback and best practices * Applied review comments across * Comprehensive monitor API fixes and improvements - Fixed json_object parameter issues in AlertDefinition properties - Corrected list type annotations for AlertChannelEnvelope - Updated integration tests with proper status handling for alert definitions - Applied review comments for better code quality - Enhanced type annotations and import organization - Improved error handling in monitor group methods * change entity id type as per review comments * reverted copilot comments as per the suggestion unit and integration test passed after the changes * removed unused imports * removed unused imports * Refactored integration test * changed _class to alert_class as per review suggestions * changed alert_class to service_class as per review suggestions * Update linode_api4/objects/monitor.py * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/groups/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/integration/models/monitor/test_monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update linode_api4/groups/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update test/fixtures/monitor_alert-definitions.json * Apply suggestion from @zliang-akamai * Apply suggestion from @zliang-akamai --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Zhiwei Liang --- linode_api4/groups/monitor.py | 145 ++++++++++- linode_api4/objects/monitor.py | 243 +++++++++++++++++- test/fixtures/monitor_alert-definitions.json | 26 ++ ...itor_services_dbaas_alert-definitions.json | 52 ++++ ...ervices_dbaas_alert-definitions_12345.json | 44 ++++ test/integration/conftest.py | 7 + .../models/monitor/test_monitor.py | 110 ++++++++ test/unit/groups/monitor_api_test.py | 76 +++++- 8 files changed, 689 insertions(+), 14 deletions(-) create mode 100644 test/fixtures/monitor_alert-definitions.json create mode 100644 test/fixtures/monitor_services_dbaas_alert-definitions.json create mode 100644 test/fixtures/monitor_services_dbaas_alert-definitions_12345.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 2dbfd2285..66943ade5 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,18 +1,21 @@ -__all__ = [ - "MonitorGroup", -] from typing import Any, Optional from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group from linode_api4.objects import ( + AlertChannel, + AlertDefinition, MonitorDashboard, MonitorMetricsDefinition, MonitorService, MonitorServiceToken, ) +__all__ = [ + "MonitorGroup", +] + class MonitorGroup(Group): """ @@ -145,3 +148,139 @@ def create_token( "Unexpected response when creating token!", json=result ) return MonitorServiceToken(token=result["token"]) + + def alert_definitions( + self, + *filters, + service_type: Optional[str] = None, + ) -> PaginatedList: + """ + Retrieve alert definitions. + + Returns a paginated collection of :class:`AlertDefinition` objects. If you + need to obtain a single :class:`AlertDefinition`, use :meth:`LinodeClient.load` + and supply the `service_type` as the parent identifier, for example: + + alerts = client.monitor.alert_definitions() + alerts_by_service = client.monitor.alert_definitions(service_type="dbaas") + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions + https://techdocs.akamai.com/linode-api/reference/get-alert-definitions-for-service-type + + :param service_type: Optional service type to scope the query (e.g. ``"dbaas"``). + :type service_type: Optional[str] + :param filters: Optional filtering expressions to apply to the returned + collection. See :doc:`Filtering Collections`. + + :returns: A paginated list of :class:`AlertDefinition` objects. + :rtype: PaginatedList[AlertDefinition] + """ + + endpoint = "/monitor/alert-definitions" + if service_type: + endpoint = f"/monitor/services/{service_type}/alert-definitions" + + # Requesting a list + return self.client._get_and_filter( + AlertDefinition, *filters, endpoint=endpoint + ) + + def alert_channels(self, *filters) -> PaginatedList: + """ + List alert channels for the authenticated account. + + Returns a paginated collection of :class:`AlertChannel` objects which + describe destinations for alert notifications (for example: email + lists, webhooks, PagerDuty, Slack, etc.). By default this method + returns all channels visible to the authenticated account; you can + supply optional filter expressions to restrict the results. + + Examples: + channels = client.monitor.alert_channels() + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections` for details. + :returns: A paginated list of :class:`AlertChannel` objects. + :rtype: PaginatedList[AlertChannel] + """ + return self.client._get_and_filter(AlertChannel, *filters) + + def create_alert_definition( + self, + service_type: str, + label: str, + severity: int, + channel_ids: list[int], + rule_criteria: dict, + trigger_conditions: dict, + entity_ids: Optional[list[str]] = None, + description: Optional[str] = None, + ) -> AlertDefinition: + """ + Create a new alert definition for a given service type. + + The alert definition configures when alerts are fired and which channels + are notified. + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-alert-definition-for-service-type + + :param service_type: Service type for which to create the alert definition + (e.g. ``"dbaas"``). + :type service_type: str + :param label: Human-readable label for the alert definition. + :type label: str + :param severity: Severity level for the alert (numeric severity used by API). + :type severity: int + :param channel_ids: List of alert channel IDs to notify when the alert fires. + :type channel_ids: list[int] + :param rule_criteria: Rule criteria that determine when the alert + should be evaluated. Structure depends on the service + metric definitions. + :type rule_criteria: dict + :param trigger_conditions: Trigger conditions that define when + the alert should transition state. + :type trigger_conditions: dict + :param entity_ids: (Optional) Restrict the alert to a subset of entity IDs. + :type entity_ids: Optional[list[str]] + :param description: (Optional) Longer description for the alert definition. + :type description: Optional[str] + + :returns: The newly created :class:`AlertDefinition`. + :rtype: AlertDefinition + + .. note:: + For updating an alert definition, use the ``save()`` method on the :class:`AlertDefinition` object. + For deleting an alert definition, use the ``delete()`` method directly on the :class:`AlertDefinition` object. + """ + params = { + "label": label, + "severity": severity, + "channel_ids": channel_ids, + "rule_criteria": rule_criteria, + "trigger_conditions": trigger_conditions, + } + if description is not None: + params["description"] = description + if entity_ids is not None: + params["entity_ids"] = entity_ids + + # API will validate service_type and return an error if missing + result = self.client.post( + f"/monitor/services/{service_type}/alert-definitions", data=params + ) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating alert definition!", + json=result, + ) + + return AlertDefinition(self.client, result["id"], service_type, result) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index fb339a0fd..4315e4c2e 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -1,15 +1,24 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Union + +from linode_api4.objects import DerivedBase +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + __all__ = [ + "AggregateFunction", + "Alert", + "AlertChannel", + "AlertDefinition", + "AlertType", + "Alerts", "MonitorDashboard", "MonitorMetricsDefinition", "MonitorService", "MonitorServiceToken", - "AggregateFunction", + "RuleCriteria", + "TriggerConditions", ] -from dataclasses import dataclass, field -from typing import List, Optional - -from linode_api4.objects.base import Base, Property -from linode_api4.objects.serializable import JSONObject, StrEnum class AggregateFunction(StrEnum): @@ -63,6 +72,15 @@ class MetricType(StrEnum): summary = "summary" +class CriteriaCondition(StrEnum): + """ + Enum for supported CriteriaCondition + Currently, only ALL is supported. + """ + + all = "ALL" + + class MetricUnit(StrEnum): """ Enum for supported metric units. @@ -226,3 +244,216 @@ class MonitorServiceToken(JSONObject): """ token: str = "" + + +@dataclass +class TriggerConditions(JSONObject): + """ + Represents the trigger/evaluation configuration for an alert. + + Expected JSON example: + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 60, + "polling_interval_seconds": 10, + "trigger_occurrences": 3 + } + + Fields: + - criteria_condition: "ALL" (currently, only "ALL" is supported) + - evaluation_period_seconds: seconds over which the rule(s) are evaluated + - polling_interval_seconds: how often metrics are sampled (seconds) + - trigger_occurrences: how many consecutive evaluation periods must match to trigger + """ + + criteria_condition: CriteriaCondition = CriteriaCondition.all + evaluation_period_seconds: int = 0 + polling_interval_seconds: int = 0 + trigger_occurrences: int = 0 + + +@dataclass +class DimensionFilter(JSONObject): + """ + A single dimension filter used inside a Rule. + + Example JSON: + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + """ + + dimension_label: str = "" + label: str = "" + operator: str = "" + value: Optional[str] = None + + +@dataclass +class Rule(JSONObject): + """ + A single rule within RuleCriteria. + Example JSON: + { + "aggregate_function": "avg", + "dimension_filters": [ ... ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 95, + "unit": "percent" + } + """ + + aggregate_function: Optional[Union[AggregateFunction, str]] = None + dimension_filters: Optional[List[DimensionFilter]] = None + label: str = "" + metric: str = "" + operator: str = "" + threshold: Optional[float] = None + unit: Optional[str] = None + + +@dataclass +class RuleCriteria(JSONObject): + """ + Container for a list of Rule objects, matching the JSON shape: + "rule_criteria": { "rules": [ { ... }, ... ] } + """ + + rules: Optional[List[Rule]] = None + + +@dataclass +class Alert(JSONObject): + """ + Represents an alert definition reference within an AlertChannel. + + Fields: + - id: int - Unique identifier of the alert definition. + - label: str - Human-readable name for the alert definition. + - type: str - Type of the alert (e.g., 'alerts-definitions'). + - url: str - API URL for the alert definition. + """ + + id: int = 0 + label: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + url: str = "" + + +@dataclass +class Alerts(JSONObject): + """ + Represents a collection of alert definitions within an AlertChannel. + + Fields: + - items: List[Alert] - List of alert definitions. + """ + + items: List[Alert] = field(default_factory=list) + + +class AlertType(StrEnum): + """ + Enumeration of alert origin types used by alert definitions. + + Values: + - system: Alerts that originate from the system (built-in or platform-managed). + - user: Alerts created and managed by users (custom alerts). + + The API uses this value in the `type` field of alert-definition responses. + This enum can be used to compare or validate the `type` value when + processing alert definitions. + """ + + system = "system" + user = "user" + + +class AlertDefinition(DerivedBase): + """ + Represents an alert definition for a monitor service. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-definition + """ + + api_endpoint = "/monitor/services/{service_type}/alert-definitions/{id}" + derived_url_path = "alert-definitions" + parent_id_name = "service_type" + id_attribute = "id" + + properties = { + "id": Property(identifier=True), + "service_type": Property(identifier=True), + "label": Property(mutable=True), + "severity": Property(mutable=True), + "type": Property(mutable=True), + "status": Property(mutable=True), + "has_more_resources": Property(mutable=True), + "rule_criteria": Property(mutable=True, json_object=RuleCriteria), + "trigger_conditions": Property( + mutable=True, json_object=TriggerConditions + ), + "alert_channels": Property(mutable=True, json_object=Alerts), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "updated_by": Property(), + "created_by": Property(), + "entity_ids": Property(mutable=True), + "description": Property(mutable=True), + "service_class": Property(alias_of="class"), + } + + +@dataclass +class EmailChannelContent(JSONObject): + """ + Represents the content for an email alert channel. + """ + + email_addresses: Optional[List[str]] = None + + +@dataclass +class ChannelContent(JSONObject): + """ + Represents the content block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailChannelContent] = None + # Other channel types like 'webhook', 'slack' could be added here as Optional fields. + + +class AlertChannel(Base): + """ + Represents an alert channel used to deliver notifications when alerts + fire. Alert channels define a destination and configuration for + notifications (for example: email lists, webhooks, PagerDuty, Slack, etc.). + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-alert-channels + + This class maps to the Monitor API's `/monitor/alert-channels` resource + and is used by the SDK to list, load, and inspect channels. + + NOTE: Only read operations are supported for AlertChannel at this time. + Create, update, and delete (CRUD) operations are not allowed. + """ + + api_endpoint = "/monitor/alert-channels/{id}" + + properties = { + "id": Property(identifier=True), + "label": Property(), + "type": Property(), + "channel_type": Property(), + "alerts": Property(mutable=False, json_object=Alerts), + "content": Property(mutable=False, json_object=ChannelContent), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "created_by": Property(), + "updated_by": Property(), + } diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json new file mode 100644 index 000000000..92b6e0e4c --- /dev/null +++ b/test/fixtures/monitor_alert-definitions.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": ["13217"], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": null, + "trigger_conditions": null, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json new file mode 100644 index 000000000..0c7067a8a --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -0,0 +1,52 @@ +{ + "data": [ + { + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "notification_groups": [], + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json new file mode 100644 index 000000000..822e18b24 --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -0,0 +1,44 @@ +{ + "id": 12345, + "label": "Test Alert for DBAAS", + "service_type": "dbaas", + "severity": 1, + "type": "user", + "description": "A test alert for dbaas service", + "entity_ids": [ + "13217" + ], + "alert_channels": [], + "has_more_resources": false, + "rule_criteria": { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary" + } + ], + "label": "High CPU Usage", + "metric": "cpu_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent" + } + ] + }, + "trigger_conditions": { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 60, + "trigger_occurrences": 3 + }, + "class": "alert", + "status": "active", + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "updated_by": "tester" +} diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 3692269dc..caac7ca01 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -34,6 +34,7 @@ ENV_REGION_OVERRIDE = "LINODE_TEST_REGION_OVERRIDE" ENV_API_CA_NAME = "LINODE_API_CA" RUN_LONG_TESTS = "RUN_LONG_TESTS" +SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" def get_token(): @@ -85,6 +86,12 @@ def run_long_tests(): @pytest.fixture(autouse=True, scope="session") def e2e_test_firewall(test_linode_client): + # Allow skipping firewall creation for local runs: set SKIP_E2E_FIREWALL=1 + if os.environ.get(SKIP_E2E_FIREWALL): + # Yield None so fixtures depending on this receive a falsy value but the session continues. + yield None + return + def is_valid_ipv4(address): try: ipaddress.IPv4Address(address) diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index eed85ab14..b6cf40b54 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -1,3 +1,4 @@ +import time from test.integration.helpers import ( get_test_label, send_request_when_resource_available, @@ -8,6 +9,8 @@ from linode_api4 import LinodeClient from linode_api4.objects import ( + AlertDefinition, + ApiError, MonitorDashboard, MonitorMetricsDefinition, MonitorService, @@ -163,3 +166,110 @@ def test_my_db_functionality(test_linode_client, test_create_and_test_db): assert isinstance(token, MonitorServiceToken) assert len(token.token) > 0, "Token should not be empty" assert hasattr(token, "token"), "Response object has no 'token' attribute" + + +def test_integration_create_get_update_delete_alert_definition( + test_linode_client, +): + """E2E: create an alert definition, fetch it, update it, then delete it. + + This test attempts to be resilient: it cleans up the created definition + in a finally block so CI doesn't leak resources. + """ + client = test_linode_client + service_type = "dbaas" + label = get_test_label() + "-e2e-alert" + + rule_criteria = { + "rules": [ + { + "aggregate_function": "avg", + "dimension_filters": [ + { + "dimension_label": "node_type", + "label": "Node Type", + "operator": "eq", + "value": "primary", + } + ], + "label": "Memory Usage", + "metric": "memory_usage", + "operator": "gt", + "threshold": 90, + "unit": "percent", + } + ] + } + trigger_conditions = { + "criteria_condition": "ALL", + "evaluation_period_seconds": 300, + "polling_interval_seconds": 300, + "trigger_occurrences": 1, + } + + # Make the label unique and ensure it begins/ends with an alphanumeric char + label = f"{label}-{int(time.time())}" + description = "E2E alert created by SDK integration test" + + # Pick an existing alert channel to attach to the definition; skip if none + channels = list(client.monitor.alert_channels()) + if not channels: + pytest.skip( + "No alert channels available on account for creating alert definitions" + ) + + created = None + + def wait_for_alert_ready(alert_id, service_type: str): + timeout = 360 # maximum time in seconds to wait for alert creation + initial_timeout = 1 + start = time.time() + interval = initial_timeout + alert = client.load(AlertDefinition, alert_id, service_type) + while ( + getattr(alert, "status", None) == "in progress" + and (time.time() - start) < timeout + ): + time.sleep(interval) + interval *= 2 + try: + alert._api_get() + except ApiError as e: + # transient errors while polling; continue until timeout + if e.status != 404: + raise + return alert + + try: + # Create the alert definition using API-compliant top-level fields + created = client.monitor.create_alert_definition( + service_type=service_type, + label=label, + severity=1, + description=description, + channel_ids=[channels[0].id], + rule_criteria=rule_criteria, + trigger_conditions=trigger_conditions, + ) + + assert created.id + assert getattr(created, "label", None) == label + + created = wait_for_alert_ready(created.id, service_type) + + updated = client.load(AlertDefinition, created.id, service_type) + updated.label = f"{label}-updated" + updated.save() + + updated = wait_for_alert_ready(updated.id, service_type) + + assert created.id == updated.id + assert updated.label == f"{label}-updated" + + finally: + if created: + # Best-effort cleanup; allow transient errors. + delete_alert = client.load( + AlertDefinition, created.id, service_type + ) + delete_alert.delete() diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index c34db068f..9515895ae 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -1,6 +1,11 @@ -from test.unit.base import MonitorClientBaseCase +from test.unit.base import ClientBaseCase, MonitorClientBaseCase -from linode_api4.objects import AggregateFunction, EntityMetricOptions +from linode_api4 import PaginatedList +from linode_api4.objects import ( + AggregateFunction, + AlertDefinition, + EntityMetricOptions, +) class MonitorAPITest(MonitorClientBaseCase): @@ -11,7 +16,7 @@ class MonitorAPITest(MonitorClientBaseCase): def test_fetch_metrics(self): service_type = "dbaas" url = f"/monitor/services/{service_type}/metrics" - with self.mock_post(url) as m: + with self.mock_post(url) as mock_post: metrics = self.client.metrics.fetch_metrics( service_type, entity_ids=[13217, 13316], @@ -26,8 +31,8 @@ def test_fetch_metrics(self): ) # assert call data - assert m.call_url == url - assert m.call_data == { + assert mock_post.call_url == url + assert mock_post.call_data == { "entity_ids": [13217, 13316], "metrics": [ {"name": "avg_read_iops", "aggregate_function": "avg"}, @@ -50,3 +55,64 @@ def test_fetch_metrics(self): assert metrics.stats.executionTimeMsec == 21 assert metrics.stats.seriesFetched == "2" assert not metrics.isPartial + + +class MonitorAlertDefinitionsTest(ClientBaseCase): + def test_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + with self.mock_get(url) as mock_get: + alert = self.client.monitor.alert_definitions( + service_type=service_type + ) + + assert mock_get.call_url == url + + # assert collection and element types + assert isinstance(alert, PaginatedList) + assert isinstance(alert[0], AlertDefinition) + + # fetch the raw JSON from the client and assert its fields + raw = self.client.get(url) + # raw is a paginated response; check first item's fields + first = raw["data"][0] + assert first["label"] == "Test Alert for DBAAS" + assert first["service_type"] == "dbaas" + assert first["status"] == "active" + assert first["created"] == "2024-01-01T00:00:00" + + def test_create_alert_definition(self): + service_type = "dbaas" + url = f"/monitor/services/{service_type}/alert-definitions" + result = { + "id": 67890, + "label": "Created Alert", + "service_type": service_type, + "severity": 1, + "status": "active", + } + + with self.mock_post(result) as mock_post: + alert = self.client.monitor.create_alert_definition( + service_type=service_type, + label="Created Alert", + severity=1, + channel_ids=[1, 2], + rule_criteria={"rules": []}, + trigger_conditions={"criteria_condition": "ALL"}, + entity_ids=["13217"], + description="created via test", + ) + + assert mock_post.call_url == url + # payload should include the provided fields + assert mock_post.call_data["label"] == "Created Alert" + assert mock_post.call_data["severity"] == 1 + assert "channel_ids" in mock_post.call_data + + assert isinstance(alert, AlertDefinition) + assert alert.id == 67890 + + # fetch the same response from the client and assert + resp = self.client.post(url, data={}) + assert resp["label"] == "Created Alert" From bbb6e71657144d9f789fc583d29ce2638e20d839 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:26:10 -0500 Subject: [PATCH 351/379] Prevent recursive build artifact inclusion (#621) --- MANIFEST.in | 5 ++++- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 96c48f6d8..d15ca4b00 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ +# Include all files under test/ directory in source distribution only graft test + +# Exclude Python bytecode global-exclude *.pyc -include baked_version \ No newline at end of file +global-exclude __pycache__ diff --git a/pyproject.toml b/pyproject.toml index 5098027af..4d8542cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ Repository = "https://github.com/linode/linode_api4-python.git" version = { attr = "linode_api4.version.__version__" } [tool.setuptools.packages.find] -exclude = ['contrib', 'docs', 'test', 'test.*'] +exclude = ['contrib', 'docs', 'build', 'build.*', 'test', 'test.*'] [tool.isort] profile = "black" From db09cc956ac6c01768c19c452e9893fbfc4134cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:42:17 -0500 Subject: [PATCH 352/379] build(deps): bump github/codeql-action from 3 to 4 (#614) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d3fa1315f..c7b208528 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,13 +26,13 @@ jobs: uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" From 99cd773262a5b7147b0c0a064e8c3d352b027486 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:06:39 -0500 Subject: [PATCH 353/379] Replace 'secondary' with 'standby' in database instance configurations and tests (#622) --- test/fixtures/databases_instances.json | 2 +- test/fixtures/databases_mysql_instances.json | 2 +- test/fixtures/databases_postgresql_instances.json | 2 +- test/unit/groups/database_test.py | 6 +++--- test/unit/objects/database_test.py | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/test/fixtures/databases_instances.json b/test/fixtures/databases_instances.json index 5e92515a5..d2e6f0cf9 100644 --- a/test/fixtures/databases_instances.json +++ b/test/fixtures/databases_instances.json @@ -11,7 +11,7 @@ "engine": "mysql", "hosts": { "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + "standby": "lin-123-456-mysql-primary-private.servers.linodedb.net" }, "id": 123, "instance_uri": "/v4/databases/mysql/instances/123", diff --git a/test/fixtures/databases_mysql_instances.json b/test/fixtures/databases_mysql_instances.json index e60bfe019..c442b8345 100644 --- a/test/fixtures/databases_mysql_instances.json +++ b/test/fixtures/databases_mysql_instances.json @@ -11,7 +11,7 @@ "engine": "mysql", "hosts": { "primary": "lin-123-456-mysql-mysql-primary.servers.linodedb.net", - "secondary": "lin-123-456-mysql-primary-private.servers.linodedb.net" + "standby": "lin-123-456-mysql-primary-private.servers.linodedb.net" }, "id": 123, "label": "example-db", diff --git a/test/fixtures/databases_postgresql_instances.json b/test/fixtures/databases_postgresql_instances.json index 47573aa12..7e22cbbc1 100644 --- a/test/fixtures/databases_postgresql_instances.json +++ b/test/fixtures/databases_postgresql_instances.json @@ -11,7 +11,7 @@ "engine": "postgresql", "hosts": { "primary": "lin-0000-000-pgsql-primary.servers.linodedb.net", - "secondary": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" + "standby": "lin-0000-000-pgsql-primary-private.servers.linodedb.net" }, "id": 123, "label": "example-db", diff --git a/test/unit/groups/database_test.py b/test/unit/groups/database_test.py index 5e2964c8d..8038e8c6b 100644 --- a/test/unit/groups/database_test.py +++ b/test/unit/groups/database_test.py @@ -54,7 +54,7 @@ def test_get_databases(self): "lin-123-456-mysql-mysql-primary.servers.linodedb.net", ) self.assertEqual( - dbs[0].hosts.secondary, + dbs[0].hosts.standby, "lin-123-456-mysql-primary-private.servers.linodedb.net", ) self.assertEqual(dbs[0].id, 123) @@ -1280,7 +1280,7 @@ def test_get_mysql_instances(self): "lin-123-456-mysql-mysql-primary.servers.linodedb.net", ) self.assertEqual( - dbs[0].hosts.secondary, + dbs[0].hosts.standby, "lin-123-456-mysql-primary-private.servers.linodedb.net", ) self.assertEqual(dbs[0].id, 123) @@ -1361,7 +1361,7 @@ def test_get_postgresql_instances(self): "lin-0000-000-pgsql-primary.servers.linodedb.net", ) self.assertEqual( - dbs[0].hosts.secondary, + dbs[0].hosts.standby, "lin-0000-000-pgsql-primary-private.servers.linodedb.net", ) self.assertEqual(dbs[0].id, 123) diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 535b2a336..10cb8fc78 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -143,7 +143,7 @@ def test_create_backup(self): # We don't care about errors here; we just want to # validate the request. try: - db.backup_create("mybackup", target="secondary") + db.backup_create("mybackup", target="standby") except Exception as e: logger.warning( "An error occurred while validating the request: %s", e @@ -154,7 +154,7 @@ def test_create_backup(self): m.call_url, "/databases/mysql/instances/123/backups" ) self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") + self.assertEqual(m.call_data["target"], "standby") def test_backup_restore(self): """ @@ -410,7 +410,7 @@ def test_create_backup(self): # We don't care about errors here; we just want to # validate the request. try: - db.backup_create("mybackup", target="secondary") + db.backup_create("mybackup", target="standby") except Exception as e: logger.warning( "An error occurred while validating the request: %s", e @@ -421,7 +421,7 @@ def test_create_backup(self): m.call_url, "/databases/postgresql/instances/123/backups" ) self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "secondary") + self.assertEqual(m.call_data["target"], "standby") def test_backup_restore(self): """ From 331eb70d24f46b24f1e45160747782b717bc5e5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:07:09 -0500 Subject: [PATCH 354/379] build(deps): bump actions/upload-artifact from 5 to 6 (#628) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 2e62e6bd5..93fa491bb 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -97,7 +97,7 @@ jobs: - name: Upload Test Report as Artifact if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: test-report-file if-no-files-found: ignore From 7e65e04fafca9a2e4d844a30179698b3e0bb7a1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:07:23 -0500 Subject: [PATCH 355/379] build(deps): bump actions/download-artifact from 5 to 7 (#627) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 7. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v5...v7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 93fa491bb..e2762ff95 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -184,7 +184,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v7 with: name: test-report-file From 6ed9f7da65a7360ecb33686e44d3c2a95d7deb61 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:03:33 -0500 Subject: [PATCH 356/379] Add support for resource lock (#624) * Add support for resource lock * Add `__all__` for lock types * Lock group and tests * Fix tests * Cleanup * Cleanup * Cleanup and fix * Bring union back * Update doc * Fix test * Remove default lock type to match API schema; fix tests * make format * Remove unused var in test --- docs/linode_api4/linode_client.rst | 9 ++ linode_api4/groups/__init__.py | 1 + linode_api4/groups/lock.py | 72 +++++++++++ linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/linode.py | 1 + linode_api4/objects/lock.py | 47 +++++++ test/fixtures/locks.json | 27 ++++ test/fixtures/locks_1.json | 10 ++ test/integration/models/lock/__init__.py | 1 + test/integration/models/lock/test_lock.py | 151 ++++++++++++++++++++++ test/unit/groups/lock_test.py | 66 ++++++++++ test/unit/objects/lock_test.py | 34 +++++ 13 files changed, 424 insertions(+) create mode 100644 linode_api4/groups/lock.py create mode 100644 linode_api4/objects/lock.py create mode 100644 test/fixtures/locks.json create mode 100644 test/fixtures/locks_1.json create mode 100644 test/integration/models/lock/__init__.py create mode 100644 test/integration/models/lock/test_lock.py create mode 100644 test/unit/groups/lock_test.py create mode 100644 test/unit/objects/lock_test.py diff --git a/docs/linode_api4/linode_client.rst b/docs/linode_api4/linode_client.rst index 9e8d135c6..8a602f1c8 100644 --- a/docs/linode_api4/linode_client.rst +++ b/docs/linode_api4/linode_client.rst @@ -125,6 +125,15 @@ Includes methods for interacting with our Longview service. :members: :special-members: +LockGroup +^^^^^^^^^^^^^ + +Includes methods for interacting with our Lock service. + +.. autoclass:: linode_api4.linode_client.LockGroup + :members: + :special-members: + NetworkingGroup ^^^^^^^^^^^^^^^ diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 6f87eeb65..3c1bc9a7f 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -9,6 +9,7 @@ from .linode import * from .lke import * from .lke_tier import * +from .lock import * from .longview import * from .maintenance import * from .monitor import * diff --git a/linode_api4/groups/lock.py b/linode_api4/groups/lock.py new file mode 100644 index 000000000..42cc58d80 --- /dev/null +++ b/linode_api4/groups/lock.py @@ -0,0 +1,72 @@ +from typing import Union + +from linode_api4.errors import UnexpectedResponseError +from linode_api4.groups import Group +from linode_api4.objects import Lock, LockType + +__all__ = ["LockGroup"] + + +class LockGroup(Group): + """ + Encapsulates methods for interacting with Resource Locks. + + Resource locks prevent deletion or modification of resources. + Currently, only Linode instances can be locked. + """ + + def __call__(self, *filters): + """ + Returns a list of all Resource Locks on the account. + + This is intended to be called off of the :any:`LinodeClient` + class, like this:: + + locks = client.locks() + + API Documentation: TBD + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Resource Locks on the account. + :rtype: PaginatedList of Lock + """ + return self.client._get_and_filter(Lock, *filters) + + def create( + self, + entity_type: str, + entity_id: Union[int, str], + lock_type: Union[LockType, str], + ) -> Lock: + """ + Creates a new Resource Lock for the specified entity. + + API Documentation: TBD + + :param entity_type: The type of entity to lock (e.g., "linode"). + :type entity_type: str + :param entity_id: The ID of the entity to lock. + :type entity_id: int | str + :param lock_type: The type of lock to create. Defaults to "cannot_delete". + :type lock_type: LockType | str + + :returns: The newly created Resource Lock. + :rtype: Lock + """ + params = { + "entity_type": entity_type, + "entity_id": entity_id, + "lock_type": lock_type, + } + + result = self.client.post("/locks", data=params) + + if "id" not in result: + raise UnexpectedResponseError( + "Unexpected response when creating lock!", json=result + ) + + return Lock(self.client, result["id"], result) diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 1d9f0bba4..73a33e6a4 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -18,6 +18,7 @@ ImageGroup, LinodeGroup, LKEGroup, + LockGroup, LongviewGroup, MaintenanceGroup, MetricsGroup, @@ -454,6 +455,9 @@ def __init__( self.monitor = MonitorGroup(self) + #: Access methods related to Resource Locks - See :any:`LockGroup` for more information. + self.locks = LockGroup(self) + super().__init__( token=token, base_url=base_url, diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 9f120310c..98d1c7a7d 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -24,3 +24,4 @@ from .placement import * from .monitor import * from .monitor_api import * +from .lock import * diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index df2694f66..fae0926d5 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -803,6 +803,7 @@ class Instance(Base): "maintenance_policy": Property( mutable=True ), # Note: This field is only available when using v4beta. + "locks": Property(unordered=True), } @property diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py new file mode 100644 index 000000000..b6552da7b --- /dev/null +++ b/linode_api4/objects/lock.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +from linode_api4.objects.base import Base, Property +from linode_api4.objects.serializable import JSONObject, StrEnum + +__all__ = ["LockType", "LockEntity", "Lock"] + + +class LockType(StrEnum): + """ + LockType defines valid values for resource lock types. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lock + """ + + cannot_delete = "cannot_delete" + cannot_delete_with_subresources = "cannot_delete_with_subresources" + + +@dataclass +class LockEntity(JSONObject): + """ + Represents the entity that is locked. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + """ + + id: int = 0 + type: str = "" + label: str = "" + url: str = "" + + +class Lock(Base): + """ + A resource lock that prevents deletion or modification of a resource. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + """ + + api_endpoint = "/locks/{id}" + + properties = { + "id": Property(identifier=True), + "lock_type": Property(), + "entity": Property(json_object=LockEntity), + } diff --git a/test/fixtures/locks.json b/test/fixtures/locks.json new file mode 100644 index 000000000..b84056b6b --- /dev/null +++ b/test/fixtures/locks.json @@ -0,0 +1,27 @@ +{ + "data": [ + { + "id": 1, + "lock_type": "cannot_delete", + "entity": { + "id": 123, + "type": "linode", + "label": "test-linode", + "url": "/v4/linode/instances/123" + } + }, + { + "id": 2, + "lock_type": "cannot_delete_with_subresources", + "entity": { + "id": 456, + "type": "linode", + "label": "another-linode", + "url": "/v4/linode/instances/456" + } + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/locks_1.json b/test/fixtures/locks_1.json new file mode 100644 index 000000000..ed7a802bf --- /dev/null +++ b/test/fixtures/locks_1.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "lock_type": "cannot_delete", + "entity": { + "id": 123, + "type": "linode", + "label": "test-linode", + "url": "/v4/linode/instances/123" + } +} diff --git a/test/integration/models/lock/__init__.py b/test/integration/models/lock/__init__.py new file mode 100644 index 000000000..1e07a34ee --- /dev/null +++ b/test/integration/models/lock/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package. diff --git a/test/integration/models/lock/test_lock.py b/test/integration/models/lock/test_lock.py new file mode 100644 index 000000000..f2139a176 --- /dev/null +++ b/test/integration/models/lock/test_lock.py @@ -0,0 +1,151 @@ +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, + send_request_when_resource_available, +) + +import pytest + +from linode_api4.objects import Lock, LockType + + +@pytest.fixture(scope="function") +def linode_for_lock(test_linode_client, e2e_test_firewall): + """ + Create a Linode instance for testing locks. + """ + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, _ = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + firewall=e2e_test_firewall, + ) + + yield linode_instance + + # Clean up any locks on the Linode before deleting it + locks = client.locks() + for lock in locks: + if ( + lock.entity.id == linode_instance.id + and lock.entity.type == "linode" + ): + lock.delete() + + send_request_when_resource_available( + timeout=100, func=linode_instance.delete + ) + + +@pytest.fixture(scope="function") +def test_lock(test_linode_client, linode_for_lock): + """ + Create a lock for testing. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + yield lock + + # Clean up lock if it still exists + try: + lock.delete() + except Exception: + pass # Lock may have been deleted by the test + + +@pytest.mark.smoke +def test_get_lock(test_linode_client, test_lock): + """ + Test that a lock can be retrieved by ID. + """ + lock = test_linode_client.load(Lock, test_lock.id) + + assert lock.id == test_lock.id + assert lock.lock_type == "cannot_delete" + assert lock.entity is not None + assert lock.entity.type == "linode" + + +def test_list_locks(test_linode_client, test_lock): + """ + Test that locks can be listed. + """ + locks = test_linode_client.locks() + + assert len(locks) > 0 + + # Verify our test lock is in the list + lock_ids = [lock.id for lock in locks] + assert test_lock.id in lock_ids + + +def test_create_lock_cannot_delete(test_linode_client, linode_for_lock): + """ + Test creating a cannot_delete lock. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + assert lock.id is not None + assert lock.lock_type == "cannot_delete" + assert lock.entity.id == linode_for_lock.id + assert lock.entity.type == "linode" + assert lock.entity.label == linode_for_lock.label + + # Clean up + lock.delete() + + +def test_create_lock_cannot_delete_with_subresources( + test_linode_client, linode_for_lock +): + """ + Test creating a cannot_delete_with_subresources lock. + """ + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete_with_subresources, + ) + + assert lock.id is not None + assert lock.lock_type == "cannot_delete_with_subresources" + assert lock.entity.id == linode_for_lock.id + assert lock.entity.type == "linode" + + # Clean up + lock.delete() + + +def test_delete_lock(test_linode_client, linode_for_lock): + """ + Test that a lock can be deleted using the Lock object's delete method. + """ + # Create a lock + lock = test_linode_client.locks.create( + entity_type="linode", + entity_id=linode_for_lock.id, + lock_type=LockType.cannot_delete, + ) + + lock_id = lock.id + + # Delete the lock using the object method + lock.delete() + + # Verify the lock no longer exists + locks = test_linode_client.locks() + lock_ids = [lk.id for lk in locks] + assert lock_id not in lock_ids diff --git a/test/unit/groups/lock_test.py b/test/unit/groups/lock_test.py new file mode 100644 index 000000000..a1e3af26a --- /dev/null +++ b/test/unit/groups/lock_test.py @@ -0,0 +1,66 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import LockType + + +class LockGroupTest(ClientBaseCase): + """ + Tests methods of the LockGroup class + """ + + def test_list_locks(self): + """ + Tests that locks can be retrieved using client.locks() + """ + locks = self.client.locks() + + self.assertEqual(len(locks), 2) + self.assertEqual(locks[0].id, 1) + self.assertEqual(locks[0].lock_type, LockType.cannot_delete) + self.assertEqual(locks[0].entity.id, 123) + self.assertEqual(locks[0].entity.type, "linode") + self.assertEqual(locks[1].id, 2) + self.assertEqual( + locks[1].lock_type, LockType.cannot_delete_with_subresources + ) + self.assertEqual(locks[1].entity.id, 456) + + def test_create_lock(self): + """ + Tests that a lock can be created using client.locks.create() + """ + with self.mock_post("/locks/1") as m: + lock = self.client.locks.create( + entity_type="linode", + entity_id=123, + lock_type=LockType.cannot_delete, + ) + + self.assertEqual(m.call_url, "/locks") + self.assertEqual(m.call_data["entity_type"], "linode") + self.assertEqual(m.call_data["entity_id"], 123) + self.assertEqual(m.call_data["lock_type"], LockType.cannot_delete) + + self.assertEqual(lock.id, 1) + self.assertEqual(lock.lock_type, LockType.cannot_delete) + self.assertIsNotNone(lock.entity) + self.assertEqual(lock.entity.id, 123) + + def test_create_lock_with_subresources(self): + """ + Tests that a lock with subresources can be created + """ + with self.mock_post("/locks/1") as m: + self.client.locks.create( + entity_type="linode", + entity_id=456, + lock_type=LockType.cannot_delete_with_subresources, + ) + + self.assertEqual(m.call_url, "/locks") + self.assertEqual(m.call_data["entity_type"], "linode") + self.assertEqual(m.call_data["entity_id"], 456) + self.assertEqual( + m.call_data["lock_type"], + LockType.cannot_delete_with_subresources, + ) diff --git a/test/unit/objects/lock_test.py b/test/unit/objects/lock_test.py new file mode 100644 index 000000000..ce630d0b6 --- /dev/null +++ b/test/unit/objects/lock_test.py @@ -0,0 +1,34 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects.lock import Lock, LockEntity + + +class LockTest(ClientBaseCase): + """ + Tests methods of the Lock class + """ + + def test_get_lock(self): + """ + Tests that a lock is loaded correctly by ID + """ + lock = Lock(self.client, 1) + + self.assertEqual(lock.id, 1) + self.assertEqual(lock.lock_type, "cannot_delete") + self.assertIsInstance(lock.entity, LockEntity) + self.assertEqual(lock.entity.id, 123) + self.assertEqual(lock.entity.type, "linode") + self.assertEqual(lock.entity.label, "test-linode") + self.assertEqual(lock.entity.url, "/v4/linode/instances/123") + + def test_delete_lock(self): + """ + Tests that a lock can be deleted using the Lock object's delete method + """ + lock = Lock(self.client, 1) + + with self.mock_delete() as m: + lock.delete() + + self.assertEqual(m.call_url, "/locks/1") From 43d8ec323e3281cf7b287ef9a896a4c6726a0eb7 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:44:35 -0500 Subject: [PATCH 357/379] Filter regions based on account availabilities in get_regions function (#625) * Filter regions based on account availabilities in get_regions function * Bypass account's region availabilities check when the token has no account access * Make ALL_ACCOUNT_AVAILABILITIES a global constant * Optimization --- test/integration/conftest.py | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index caac7ca01..a5c832f4f 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -1,4 +1,5 @@ import ipaddress +import logging import os import random import time @@ -26,6 +27,7 @@ PlacementGroupType, PostgreSQLDatabase, ) +from linode_api4.errors import ApiError from linode_api4.linode_client import LinodeClient, MonitorClient from linode_api4.objects import Region @@ -36,6 +38,15 @@ RUN_LONG_TESTS = "RUN_LONG_TESTS" SKIP_E2E_FIREWALL = "SKIP_E2E_FIREWALL" +ALL_ACCOUNT_AVAILABILITIES = { + "Linodes", + "NodeBalancers", + "Block Storage", + "Kubernetes", +} + +logger = logging.getLogger(__name__) + def get_token(): return os.environ.get(ENV_TOKEN_NAME, None) @@ -58,9 +69,40 @@ def get_regions( regions = client.regions() + account_regional_availabilities = {} + try: + account_availabilities = client.account.availabilities() + for availability in account_availabilities: + account_regional_availabilities[availability.region] = ( + availability.available + ) + except ApiError: + logger.warning( + "Failed to retrieve account availabilities for regions. " + "Assuming required capabilities are available in all regions for this account. " + "Tests may fail if the account lacks access to necessary capabilities in the selected region." + ) + if capabilities is not None: + required_capabilities = set(capabilities) + required_account_capabilities = required_capabilities.intersection( + ALL_ACCOUNT_AVAILABILITIES + ) + regions = [ - v for v in regions if set(capabilities).issubset(v.capabilities) + v + for v in regions + if required_capabilities.issubset(v.capabilities) + and required_account_capabilities.issubset( + account_regional_availabilities.get( + v.id, + ( + [] + if account_regional_availabilities + else ALL_ACCOUNT_AVAILABILITIES + ), + ) + ) ] if site_type is not None: From f08c0cd6b4f403e1af02a7ea232bee228cb3fe16 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Fri, 16 Jan 2026 10:33:27 -0500 Subject: [PATCH 358/379] Project: Private Image Sharing (#633) * Added support for Private Image Sharing features and unit tests * Addressed PR comments * Integration tests for private image sharing (#632) * Create integration tests for share groups - part 1 * Create test test_try_to_add_member_invalid_token * Update integration tests for private image sharing feature * Apply code review sugestions --------- Co-authored-by: Erik Zilber --------- Co-authored-by: Pawel <100145168+psnoch-akamai@users.noreply.github.com> --- linode_api4/groups/__init__.py | 1 + linode_api4/groups/image_share_group.py | 142 ++++++++ linode_api4/groups/linode.py | 4 +- linode_api4/linode_client.py | 4 + linode_api4/objects/__init__.py | 1 + linode_api4/objects/image.py | 34 ++ linode_api4/objects/image_share_group.py | 344 ++++++++++++++++++ test/fixtures/images.json | 22 +- .../images_private_1234_sharegroups.json | 19 + test/fixtures/images_sharegroups.json | 31 ++ test/fixtures/images_sharegroups_1234.json | 12 + .../images_sharegroups_1234_images.json | 45 +++ ...ages_sharegroups_1234_images_shared_1.json | 41 +++ .../images_sharegroups_1234_members.json | 15 + ...mages_sharegroups_1234_members_abc123.json | 8 + test/fixtures/images_sharegroups_tokens.json | 18 + .../images_sharegroups_tokens_abc123.json | 12 + ..._sharegroups_tokens_abc123_sharegroup.json | 9 + ...roups_tokens_abc123_sharegroup_images.json | 45 +++ .../linode_client/test_linode_client.py | 6 +- .../models/sharegroups/test_sharegroups.py | 251 +++++++++++++ test/unit/groups/image_share_group_test.py | 153 ++++++++ test/unit/groups/linode_test.py | 5 +- test/unit/objects/image_share_group_test.py | 295 +++++++++++++++ test/unit/objects/image_test.py | 2 + 25 files changed, 1503 insertions(+), 16 deletions(-) create mode 100644 linode_api4/groups/image_share_group.py create mode 100644 linode_api4/objects/image_share_group.py create mode 100644 test/fixtures/images_private_1234_sharegroups.json create mode 100644 test/fixtures/images_sharegroups.json create mode 100644 test/fixtures/images_sharegroups_1234.json create mode 100644 test/fixtures/images_sharegroups_1234_images.json create mode 100644 test/fixtures/images_sharegroups_1234_images_shared_1.json create mode 100644 test/fixtures/images_sharegroups_1234_members.json create mode 100644 test/fixtures/images_sharegroups_1234_members_abc123.json create mode 100644 test/fixtures/images_sharegroups_tokens.json create mode 100644 test/fixtures/images_sharegroups_tokens_abc123.json create mode 100644 test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json create mode 100644 test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json create mode 100644 test/integration/models/sharegroups/test_sharegroups.py create mode 100644 test/unit/groups/image_share_group_test.py create mode 100644 test/unit/objects/image_share_group_test.py diff --git a/linode_api4/groups/__init__.py b/linode_api4/groups/__init__.py index 3c1bc9a7f..c835972bc 100644 --- a/linode_api4/groups/__init__.py +++ b/linode_api4/groups/__init__.py @@ -6,6 +6,7 @@ from .database import * from .domain import * from .image import * +from .image_share_group import * from .linode import * from .lke import * from .lke_tier import * diff --git a/linode_api4/groups/image_share_group.py b/linode_api4/groups/image_share_group.py new file mode 100644 index 000000000..e932f400b --- /dev/null +++ b/linode_api4/groups/image_share_group.py @@ -0,0 +1,142 @@ +from typing import Optional + +from linode_api4.groups import Group +from linode_api4.objects import ( + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupToken, +) +from linode_api4.objects.base import _flatten_request_body_recursive +from linode_api4.util import drop_null_keys + + +class ImageShareGroupAPIGroup(Group): + """ + Collections related to Private Image Sharing. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + """ + + def __call__(self, *filters): + """ + Retrieves a list of Image Share Groups created by the user (producer). + You can filter this query to retrieve only Image Share Groups + relevant to a specific query, for example:: + + filtered_share_groups = client.sharegroups( + ImageShareGroup.label == "my-label") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroups + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Image Share Groups. + :rtype: PaginatedList of ImageShareGroup + """ + return self.client._get_and_filter(ImageShareGroup, *filters) + + def sharegroups_by_image_id(self, image_id: str): + """ + Retrieves a list of Image Share Groups that share a specific Private Image. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-images-sharegroups-image + + :param image_id: The ID of the Image to query for. + :type image_id: str + + :returns: A list of Image Share Groups sharing the specified Image. + :rtype: PaginatedList of ImageShareGroup + """ + return self.client._get_and_filter( + ImageShareGroup, endpoint="/images/{}/sharegroups".format(image_id) + ) + + def tokens(self, *filters): + """ + Retrieves a list of Image Share Group Tokens created by the user (consumer). + You can filter this query to retrieve only Image Share Group Tokens + relevant to a specific query, for example:: + + filtered_share_group_tokens = client.sharegroups.tokens( + ImageShareGroupToken.label == "my-label") + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-user-tokens + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of Image Share Group Tokens. + :rtype: PaginatedList of ImageShareGroupToken + """ + return self.client._get_and_filter(ImageShareGroupToken, *filters) + + def create_sharegroup( + self, + label: Optional[str] = None, + description: Optional[str] = None, + images: Optional[ImageShareGroupImagesToAdd] = None, + ): + """ + Creates a new Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroups + + :param label: The label for the resulting Image Share Group. + :type label: str + :param description: The description for the new Image Share Group. + :type description: str + :param images: A list of Images to share in the new Image Share Group, formatted in JSON. + :type images: Optional[ImageShareGroupImagesToAdd] + + :returns: The new Image Share Group. + :rtype: ImageShareGroup + """ + params = { + "label": label, + "description": description, + } + + if images: + params["images"] = images + + result = self.client.post( + "/images/sharegroups", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + return ImageShareGroup(self.client, result["id"], result) + + def create_token( + self, valid_for_sharegroup_uuid: str, label: Optional[str] = None + ): + """ + Creates a new Image Share Group Token and returns the token value. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-tokens + + :param valid_for_sharegroup_uuid: The UUID of the Image Share Group that this token will be valid for. + :type valid_for_sharegroup_uuid: Optional[str] + :param label: The label for the resulting Image Share Group Token. + :type label: str + + :returns: The new Image Share Group Token object and the one-time use token itself. + :rtype: (ImageShareGroupToken, str) + """ + params = {"valid_for_sharegroup_uuid": valid_for_sharegroup_uuid} + + if label: + params["label"] = label + + result = self.client.post( + "/images/sharegroups/tokens", + data=_flatten_request_body_recursive(drop_null_keys(params)), + ) + + token_value = result.pop("token", None) + token_obj = ImageShareGroupToken( + self.client, result["token_uuid"], result + ) + return token_obj, token_value diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index e12e9cf48..f88808e64 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -23,9 +23,7 @@ NetworkInterface, _expand_placement_group_assignment, ) -from linode_api4.objects.linode_interfaces import ( - LinodeInterfaceOptions, -) +from linode_api4.objects.linode_interfaces import LinodeInterfaceOptions from linode_api4.util import drop_null_keys diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 73a33e6a4..0e89142b3 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -16,6 +16,7 @@ DatabaseGroup, DomainGroup, ImageGroup, + ImageShareGroupAPIGroup, LinodeGroup, LKEGroup, LockGroup, @@ -441,6 +442,9 @@ def __init__( #: Access methods related to Images - See :any:`ImageGroup` for more information. self.images = ImageGroup(self) + #: Access methods related to Image Share Groups - See :any:`ImageShareGroupAPIGroup` for more information. + self.sharegroups = ImageShareGroupAPIGroup(self) + #: Access methods related to VPCs - See :any:`VPCGroup` for more information. self.vpcs = VPCGroup(self) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 98d1c7a7d..009e9436e 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -24,4 +24,5 @@ from .placement import * from .monitor import * from .monitor_api import * +from .image_share_group import * from .lock import * diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 1215c422c..50dc23f74 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -30,6 +30,38 @@ class ImageRegion(JSONObject): status: Optional[ReplicationStatus] = None +@dataclass +class ImageSharingSharedWith(JSONObject): + """ + Data representing who an Image has been shared with. + """ + + sharegroup_count: Optional[int] = None + sharegroup_list_url: Optional[str] = None + + +@dataclass +class ImageSharingSharedBy(JSONObject): + """ + Data representing who shared an Image. + """ + + sharegroup_id: Optional[int] = None + sharegroup_uuid: Optional[str] = None + sharegroup_label: Optional[str] = None + source_image_id: Optional[str] = None + + +@dataclass +class ImageSharing(JSONObject): + """ + The Image Sharing status of an Image. + """ + + shared_with: Optional[ImageSharingSharedWith] = None + shared_by: Optional[ImageSharingSharedBy] = None + + class Image(Base): """ An Image is something a Linode Instance or Disk can be deployed from. @@ -51,6 +83,7 @@ class Image(Base): "updated": Property(is_datetime=True), "type": Property(), "is_public": Property(), + "is_shared": Property(), "vendor": Property(), "size": Property(), "deprecated": Property(), @@ -60,6 +93,7 @@ class Image(Base): "tags": Property(mutable=True, unordered=True), "total_size": Property(), "regions": Property(json_object=ImageRegion, unordered=True), + "image_sharing": Property(json_object=ImageSharing), } def replicate(self, regions: Union[List[str], List[Region]]): diff --git a/linode_api4/objects/image_share_group.py b/linode_api4/objects/image_share_group.py new file mode 100644 index 000000000..6c75fc7f9 --- /dev/null +++ b/linode_api4/objects/image_share_group.py @@ -0,0 +1,344 @@ +__all__ = [ + "ImageShareGroupImageToAdd", + "ImageShareGroupImagesToAdd", + "ImageShareGroupImageToUpdate", + "ImageShareGroupMemberToAdd", + "ImageShareGroupMemberToUpdate", + "ImageShareGroup", + "ImageShareGroupToken", +] +from dataclasses import dataclass +from typing import List, Optional + +from linode_api4.objects import Base, MappedObject, Property +from linode_api4.objects.serializable import JSONObject + + +@dataclass +class ImageShareGroupImageToAdd(JSONObject): + """ + Data representing an Image to add to an Image Share Group. + """ + + id: str + label: Optional[str] = None + description: Optional[str] = None + + def to_dict(self): + d = {"id": self.id} + if self.label is not None: + d["label"] = self.label + if self.description is not None: + d["description"] = self.description + return d + + +@dataclass +class ImageShareGroupImagesToAdd(JSONObject): + """ + Data representing a list of Images to add to an Image Share Group. + """ + + images: List[ImageShareGroupImageToAdd] + + +@dataclass +class ImageShareGroupImageToUpdate(JSONObject): + """ + Data to update an Image shared in an Image Share Group. + """ + + image_share_id: str + label: Optional[str] = None + description: Optional[str] = None + + def to_dict(self): + d = {"image_share_id": self.image_share_id} + if self.label is not None: + d["label"] = self.label + if self.description is not None: + d["description"] = self.description + return d + + +@dataclass +class ImageShareGroupMemberToAdd(JSONObject): + """ + Data representing a Member to add to an Image Share Group. + """ + + token: str + label: str + + +@dataclass +class ImageShareGroupMemberToUpdate(JSONObject): + """ + Data to update a Member in an Image Share Group. + """ + + token_uuid: str + label: str + + +class ImageShareGroup(Base): + """ + An Image Share Group is a group to share private images with other users. This class is intended + to be used by a Producer of an Image Share Group, and not a Consumer. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup + """ + + api_endpoint = "/images/sharegroups/{id}" + + properties = { + "id": Property(identifier=True), + "uuid": Property(), + "label": Property(mutable=True), + "description": Property(mutable=True), + "is_suspended": Property(), + "images_count": Property(), + "members_count": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + } + + def add_images(self, images: ImageShareGroupImagesToAdd): + """ + Add private images to be shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-images + + :param images: A list of Images to share in the Image Share Group, formatted in JSON. + :type images: ImageShareGroupImagesToAdd + + :returns: A list of the new Image shares. + :rtype: List of MappedObject + """ + params = {"images": [img.to_dict() for img in images.images]} + + result = self._client.post( + "{}/images".format(self.api_endpoint), model=self, data=params + ) + + # Sync this object to reflect the new images added to the share group. + self.invalidate() + + # Expect result to be a dict with a 'data' key + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] + + def get_image_shares(self): + """ + Retrieves a list of images shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images + + :returns: A list of the Image shares. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/images".format(self.api_endpoint), + model=self, + ) + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] + + def update_image_share(self, image: ImageShareGroupImageToUpdate): + """ + Update the label and description of an Image shared in the Image Share Group. + Note that the ID provided in the image parameter must be the shared ID of an + Image already shared in the Image Share Group, not the private ID. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-sharegroup-imageshare + + :param image: The Image to update, formatted in JSON. + :type image: ImageShareGroupImageToUpdate + + :returns: The updated Image share. + :rtype: MappedObject + """ + params = image.to_dict() + + result = self._client.put( + "{}/images/{}".format(self.api_endpoint, image.image_share_id), + model=self, + data=params, + ) + + return MappedObject(**result) + + def revoke_image_share(self, image_share_id: str): + """ + Revoke an Image shared in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-sharegroup-imageshare + + :param image_share_id: The ID of the Image share to revoke. + :type image_share_id: str + """ + self._client.delete( + "{}/images/{}".format(self.api_endpoint, image_share_id), model=self + ) + + # Sync this object to reflect the revoked image share. + self.invalidate() + + def add_member(self, member: ImageShareGroupMemberToAdd): + """ + Add a Member to the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-sharegroup-members + + :param member: The Member to add, formatted in JSON. + :type member: ImageShareGroupMemberToAdd + + :returns: The new Member. + :rtype: MappedObject + """ + params = { + "token": member.token, + "label": member.label, + } + + result = self._client.post( + "{}/members".format(self.api_endpoint), model=self, data=params + ) + + # Sync this object to reflect the new member added to the share group. + self.invalidate() + + return MappedObject(**result) + + def get_members(self): + """ + Retrieves a list of members in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-members + + :returns: List of members. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/members".format(self.api_endpoint), + model=self, + ) + member_list = result.get("data", []) + return [MappedObject(**item) for item in member_list] + + def get_member(self, token_uuid: str): + """ + Get a Member in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-member-token + + :param token_uuid: The UUID of the token corresponding to the Member to retrieve. + :type token_uuid: str + + :returns: The requested Member. + :rtype: MappedObject + """ + result = self._client.get( + "{}/members/{}".format(self.api_endpoint, token_uuid), model=self + ) + + return MappedObject(**result) + + def update_member(self, member: ImageShareGroupMemberToUpdate): + """ + Update the label of a Member in the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-sharegroup-member-token + + :param member: The Member to update, formatted in JSON. + :type member: ImageShareGroupMemberToUpdate + + :returns: The updated Member. + :rtype: MappedObject + """ + params = { + "label": member.label, + } + + result = self._client.put( + "{}/members/{}".format(self.api_endpoint, member.token_uuid), + model=self, + data=params, + ) + + return MappedObject(**result) + + def remove_member(self, token_uuid: str): + """ + Remove a Member from the Image Share Group. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-sharegroup-member-token + + :param token_uuid: The UUID of the token corresponding to the Member to remove. + :type token_uuid: str + """ + self._client.delete( + "{}/members/{}".format(self.api_endpoint, token_uuid), model=self + ) + + # Sync this object to reflect the removed member. + self.invalidate() + + +class ImageShareGroupToken(Base): + """ + An Image Share Group Token is a token that can be used to access the Images shared in an Image Share Group. + This class is intended to be used by a Consumer of an Image Share Group, and not a Producer. + + NOTE: Private Image Sharing features are in beta and may not be generally available. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-token + """ + + api_endpoint = "/images/sharegroups/tokens/{token_uuid}" + id_attribute = "token_uuid" + properties = { + "token_uuid": Property(identifier=True), + "status": Property(), + "label": Property(mutable=True), + "valid_for_sharegroup_uuid": Property(), + "created": Property(is_datetime=True), + "updated": Property(is_datetime=True), + "expiry": Property(is_datetime=True), + "sharegroup_uuid": Property(), + "sharegroup_label": Property(), + } + + def get_sharegroup(self): + """ + Gets details about the Image Share Group that this token provides access to. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-by-token + + :returns: The requested Image Share Group. + :rtype: MappedObject + """ + result = self._client.get( + "{}/sharegroup".format(self.api_endpoint), model=self + ) + + return MappedObject(**result) + + def get_images(self): + """ + Retrieves a paginated list of images shared in the Image Share Group that this token provides access to. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-sharegroup-images-by-token + + :returns: List of images. + :rtype: List of MappedObject + """ + result = self._client.get( + "{}/sharegroup/images".format(self.api_endpoint), + model=self, + ) + image_list = result.get("data", []) + return [MappedObject(**item) for item in image_list] diff --git a/test/fixtures/images.json b/test/fixtures/images.json index 357110bc7..37b31445f 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -26,7 +26,9 @@ "region": "us-east", "status": "available" } - ] + ], + "is_shared": false, + "image_sharing": null }, { "created": "2017-01-01T00:01:01", @@ -55,7 +57,9 @@ "region": "us-mia", "status": "pending" } - ] + ], + "is_shared": false, + "image_sharing": null }, { "created": "2017-01-01T00:01:01", @@ -72,7 +76,9 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "is_shared": false, + "image_sharing": null }, { "created": "2017-08-20T14:01:01", @@ -89,7 +95,15 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": ["cloud-init"] + "capabilities": ["cloud-init"], + "is_shared": false, + "image_sharing": { + "shared_by": null, + "shared_with": { + "sharegroup_count": 0, + "sharegroup_list_url": "/images/private/123/sharegroups" + } + } } ] } \ No newline at end of file diff --git a/test/fixtures/images_private_1234_sharegroups.json b/test/fixtures/images_private_1234_sharegroups.json new file mode 100644 index 000000000..925b12627 --- /dev/null +++ b/test/fixtures/images_private_1234_sharegroups.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1, + "images_count": 1, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/images_sharegroups.json b/test/fixtures/images_sharegroups.json new file mode 100644 index 000000000..53b54c07a --- /dev/null +++ b/test/fixtures/images_sharegroups.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1, + "images_count": 0, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" + }, + { + "created": "2025-04-14T22:44:03", + "description": "My other group of images to share with my team.", + "expiry": null, + "id": 2, + "images_count": 1, + "is_suspended": false, + "label": "My other Shared Images", + "members_count": 3, + "updated": null, + "uuid": "30ee6599-eb0f-478c-9e55-4073c6c24a39" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/images_sharegroups_1234.json b/test/fixtures/images_sharegroups_1234.json new file mode 100644 index 000000000..9817ea3d9 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234.json @@ -0,0 +1,12 @@ +{ + "created": "2025-04-14T22:44:02", + "description": "My group of images to share with my team.", + "expiry": null, + "id": 1234, + "images_count": 0, + "is_suspended": false, + "label": "My Shared Images", + "members_count": 0, + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_images.json b/test/fixtures/images_sharegroups_1234_images.json new file mode 100644 index 000000000..f63e52392 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_images.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_images_shared_1.json b/test/fixtures/images_sharegroups_1234_images_shared_1.json new file mode 100644 index 000000000..1b1179c93 --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_images_shared_1.json @@ -0,0 +1,41 @@ +{ + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } +} diff --git a/test/fixtures/images_sharegroups_1234_members.json b/test/fixtures/images_sharegroups_1234_members.json new file mode 100644 index 000000000..424f8b23c --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_members.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "created": "2025-08-04T10:07:59", + "expiry": null, + "label": "New Member", + "status": "active", + "token_uuid": "4591075e-4ba8-43c9-a521-928c3d4a135d", + "updated": null + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_1234_members_abc123.json b/test/fixtures/images_sharegroups_1234_members_abc123.json new file mode 100644 index 000000000..156458ccc --- /dev/null +++ b/test/fixtures/images_sharegroups_1234_members_abc123.json @@ -0,0 +1,8 @@ +{ + "created": "2025-08-04T10:07:59", + "expiry": null, + "label": "New Member", + "status": "active", + "token_uuid": "abc123", + "updated": null +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens.json b/test/fixtures/images_sharegroups_tokens.json new file mode 100644 index 000000000..916ae8ae6 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "created": "2025-08-04T10:09:09", + "expiry": null, + "label": "My Sharegroup Token", + "sharegroup_label": "A Sharegroup", + "sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "status": "active", + "token_uuid": "13428362-5458-4dad-b14b-8d0d4d648f8c", + "updated": null, + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} diff --git a/test/fixtures/images_sharegroups_tokens_abc123.json b/test/fixtures/images_sharegroups_tokens_abc123.json new file mode 100644 index 000000000..d7d4d045d --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123.json @@ -0,0 +1,12 @@ +{ + "created": "2025-08-04T10:09:09", + "expiry": null, + "label": "My Sharegroup Token", + "sharegroup_label": "A Sharegroup", + "sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "status": "active", + "token_uuid": "abc123", + "updated": null, + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + "token": "asupersecrettoken" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json new file mode 100644 index 000000000..2dfd5e928 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup.json @@ -0,0 +1,9 @@ +{ + "created": "2025-04-14T22:44:02", + "description": "Group of base operating system images and engineers used for CI/CD pipelines and infrastructure automation", + "id": 1234, + "is_suspended": false, + "label": "DevOps Base Images", + "updated": null, + "uuid": "1533863e-16a4-47b5-b829-ac0f35c13278" +} \ No newline at end of file diff --git a/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json new file mode 100644 index 000000000..f63e52392 --- /dev/null +++ b/test/fixtures/images_sharegroups_tokens_abc123_sharegroup_images.json @@ -0,0 +1,45 @@ +{ + "data": [ + { + "capabilities": [ + "cloud-init", + "distributed-sites" + ], + "created": "2021-08-14T22:44:02", + "created_by": null, + "deprecated": false, + "description": "Example image description.", + "eol": "2026-07-01T04:00:00", + "expiry": null, + "id": "shared/1", + "is_public": true, + "is_shared": null, + "label": "Debian 11", + "regions": [ + { + "region": "us-iad", + "status": "available" + } + ], + "size": 2500, + "status": "available", + "tags": [ + "repair-image", + "fix-1" + ], + "total_size": 1234567, + "type": "manual", + "updated": "2021-08-14T22:44:02", + "vendor": null, + "image_sharing": { + "shared_with": null, + "shared_by": { + "sharegroup_id": 1234, + "sharegroup_uuid": "0ee8e1c1-b19b-4052-9487-e3b13faac111", + "sharegroup_label": "test-group-minecraft-1", + "source_image_id": null + } + } + } + ] +} \ No newline at end of file diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index da7e93cef..eb1b06369 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -6,11 +6,7 @@ import pytest from linode_api4 import ApiError -from linode_api4.objects import ( - ConfigInterface, - ObjectStorageKeys, - Region, -) +from linode_api4.objects import ConfigInterface, ObjectStorageKeys, Region @pytest.fixture(scope="session") diff --git a/test/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py new file mode 100644 index 000000000..9c66bad90 --- /dev/null +++ b/test/integration/models/sharegroups/test_sharegroups.py @@ -0,0 +1,251 @@ +import datetime +from test.integration.conftest import get_region +from test.integration.helpers import ( + get_test_label, +) + +import pytest + +from linode_api4.objects import ( + Image, + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupImageToAdd, + ImageShareGroupImageToUpdate, + ImageShareGroupMemberToAdd, + ImageShareGroupMemberToUpdate, + ImageShareGroupToken, +) + + +def wait_for_image_status( + test_linode_client, image_id, expected_status, timeout=360, interval=5 +): + import time + + get_image = test_linode_client.load(Image, image_id) + timer = 0 + while get_image.status != expected_status and timer < timeout: + time.sleep(interval) + timer += interval + get_image = test_linode_client.load(Image, image_id) + if timer >= timeout: + raise TimeoutError( + f"Created image did not reach status '{expected_status}' within {timeout} seconds." + ) + + +@pytest.fixture(scope="class") +def sample_linode(test_linode_client, e2e_test_firewall): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + linode_instance, password = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/alpine3.19", + label=label + "_modlinode", + ) + yield linode_instance + linode_instance.delete() + + +@pytest.fixture(scope="class") +def create_image_id(test_linode_client, sample_linode): + create_image = test_linode_client.images.create( + sample_linode.disks[0], + label="linode-api4python-test-image-sharing-image", + ) + wait_for_image_status(test_linode_client, create_image.id, "available") + yield create_image.id + create_image.delete() + + +@pytest.fixture(scope="function") +def share_group_id(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + group = test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python", + ) + yield group.id + group.delete() + + +def test_get_share_groups(test_linode_client, share_group_id): + response = test_linode_client.sharegroups() + sharegroups_list = response.lists[0] + assert len(sharegroups_list) > 0 + assert sharegroups_list[0].api_endpoint == "/images/sharegroups/{id}" + assert sharegroups_list[0].id > 0 + assert sharegroups_list[0].description != "" + assert isinstance(sharegroups_list[0].images_count, int) + assert not sharegroups_list[0].is_suspended + assert sharegroups_list[0].label != "" + assert isinstance(sharegroups_list[0].members_count, int) + assert sharegroups_list[0].uuid != "" + assert isinstance(sharegroups_list[0].created, datetime.date) + assert not sharegroups_list[0].expiry + + +def test_add_update_remove_share_group(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + share_group = test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python create", + ) + assert share_group.api_endpoint == "/images/sharegroups/{id}" + assert share_group.id > 0 + assert share_group.description == "Test api4python create" + assert isinstance(share_group.images_count, int) + assert not share_group.is_suspended + assert share_group.label == group_label + assert isinstance(share_group.members_count, int) + assert share_group.uuid != "" + assert isinstance(share_group.created, datetime.date) + assert not share_group.updated + assert not share_group.expiry + + load_share_group = test_linode_client.load(ImageShareGroup, share_group.id) + assert load_share_group.id == share_group.id + assert load_share_group.description == "Test api4python create" + + load_share_group.label = "Updated Sharegroup Label" + load_share_group.description = "Test update description" + load_share_group.save() + load_share_group_after_update = test_linode_client.load( + ImageShareGroup, share_group.id + ) + assert load_share_group_after_update.id == share_group.id + assert load_share_group_after_update.label == "Updated Sharegroup Label" + assert ( + load_share_group_after_update.description == "Test update description" + ) + + share_group.delete() + with pytest.raises(RuntimeError) as err: + test_linode_client.load(ImageShareGroup, share_group.id) + assert "[404] Not found" in str(err.value) + + +def test_add_get_update_revoke_image_to_share_group( + test_linode_client, create_image_id, share_group_id +): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + add_image_response = share_group.add_images( + ImageShareGroupImagesToAdd( + images=[ + ImageShareGroupImageToAdd(id=create_image_id), + ] + ) + ) + assert 0 < len(add_image_response) + assert ( + add_image_response[0].image_sharing.shared_by.sharegroup_id + == share_group.id + ) + assert ( + add_image_response[0].image_sharing.shared_by.source_image_id + == create_image_id + ) + + get_response = share_group.get_image_shares() + assert 0 < len(get_response) + assert ( + get_response[0].image_sharing.shared_by.sharegroup_id == share_group.id + ) + assert ( + get_response[0].image_sharing.shared_by.source_image_id + == create_image_id + ) + assert get_response[0].description == "" + + update_response = share_group.update_image_share( + ImageShareGroupImageToUpdate( + image_share_id=get_response[0].id, description="Description update" + ) + ) + assert update_response.description == "Description update" + + share_groups_by_image_id_response = ( + test_linode_client.sharegroups.sharegroups_by_image_id(create_image_id) + ) + assert 0 < len(share_groups_by_image_id_response.lists) + assert share_groups_by_image_id_response.lists[0][0].id == share_group.id + + share_group.revoke_image_share(get_response[0].id) + get_after_revoke_response = share_group.get_image_shares() + assert len(get_after_revoke_response) == 0 + + +def test_list_tokens(test_linode_client): + response = test_linode_client.sharegroups.tokens() + assert response.page_endpoint == "images/sharegroups/tokens" + assert len(response.lists[0]) >= 0 + + +def test_create_token_to_own_share_group_error(test_linode_client): + group_label = get_test_label(8) + "_sharegroup_api4_test" + response_create_share_group = ( + test_linode_client.sharegroups.create_sharegroup( + label=group_label, + description="Test api4python create", + ) + ) + with pytest.raises(RuntimeError) as err: + test_linode_client.sharegroups.create_token( + response_create_share_group.uuid + ) + assert "[400] valid_for_sharegroup_uuid" in str(err.value) + assert "You may not create a token for your own sharegroup" in str( + err.value + ) + + response_create_share_group.delete() + + +def test_get_invalid_token(test_linode_client): + with pytest.raises(RuntimeError) as err: + test_linode_client.load(ImageShareGroupToken, "36b0-4d52_invalid") + assert "[404] Not found" in str(err.value) + + +def test_try_to_add_member_invalid_token(test_linode_client, share_group_id): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + with pytest.raises(RuntimeError) as err: + share_group.add_member( + ImageShareGroupMemberToAdd( + token="not_existing_token", + label="New Member", + ) + ) + assert "[500] Invalid token format" in str(err.value) + + +def test_list_share_group_members(test_linode_client, share_group_id): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + response = share_group.get_members() + assert 0 == len(response) + + +def test_try_to_get_update_revoke_share_group_member_by_invalid_token( + test_linode_client, share_group_id +): + share_group = test_linode_client.load(ImageShareGroup, share_group_id) + with pytest.raises(RuntimeError) as err: + share_group.get_member("not_existing_token") + assert "[404] Not found" in str(err.value) + + with pytest.raises(RuntimeError) as err: + share_group.update_member( + ImageShareGroupMemberToUpdate( + token_uuid="not_existing_token", + label="Update Member", + ) + ) + assert "[404] Not found" in str(err.value) + + with pytest.raises(RuntimeError) as err: + share_group.remove_member("not_existing_token") + assert "[404] Not found" in str(err.value) diff --git a/test/unit/groups/image_share_group_test.py b/test/unit/groups/image_share_group_test.py new file mode 100644 index 000000000..c9787264f --- /dev/null +++ b/test/unit/groups/image_share_group_test.py @@ -0,0 +1,153 @@ +from test.unit.base import ClientBaseCase + + +class ImageTest(ClientBaseCase): + """ + Tests methods of the ImageShareGroupAPIGroup class + """ + + def test_image_share_groups(self): + """ + Test that Image Share Groups can be retrieved successfully. + """ + sharegroups = self.client.sharegroups() + self.assertEqual(len(sharegroups), 2) + + self.assertEqual(sharegroups[0].id, 1) + self.assertEqual( + sharegroups[0].description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroups[0].images_count, 0) + self.assertEqual(sharegroups[0].is_suspended, False) + self.assertEqual(sharegroups[0].label, "My Shared Images") + self.assertEqual(sharegroups[0].members_count, 0) + self.assertEqual( + sharegroups[0].uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + self.assertEqual(sharegroups[1].id, 2) + self.assertEqual( + sharegroups[1].description, + "My other group of images to share with my team.", + ) + self.assertEqual(sharegroups[1].images_count, 1) + self.assertEqual(sharegroups[1].is_suspended, False) + self.assertEqual(sharegroups[1].label, "My other Shared Images") + self.assertEqual(sharegroups[1].members_count, 3) + self.assertEqual( + sharegroups[1].uuid, "30ee6599-eb0f-478c-9e55-4073c6c24a39" + ) + + def test_image_share_groups_by_image_id(self): + """ + Test that Image Share Groups where a given private image is currently shared can be retrieved successfully. + """ + + sharegroups = self.client.sharegroups.sharegroups_by_image_id( + "private/1234" + ) + self.assertEqual(len(sharegroups), 1) + + self.assertEqual(sharegroups[0].id, 1) + self.assertEqual( + sharegroups[0].description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroups[0].images_count, 1) + self.assertEqual(sharegroups[0].is_suspended, False) + self.assertEqual(sharegroups[0].label, "My Shared Images") + self.assertEqual(sharegroups[0].members_count, 0) + self.assertEqual( + sharegroups[0].uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_image_share_group_tokens(self): + """ + Test that Image Share Group tokens can be retrieved successfully. + """ + + tokens = self.client.sharegroups.tokens() + self.assertEqual(len(tokens), 1) + + self.assertEqual( + tokens[0].token_uuid, "13428362-5458-4dad-b14b-8d0d4d648f8c" + ) + self.assertEqual(tokens[0].label, "My Sharegroup Token") + self.assertEqual(tokens[0].sharegroup_label, "A Sharegroup") + self.assertEqual( + tokens[0].sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual( + tokens[0].valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + self.assertEqual(tokens[0].status, "active") + + def test_image_share_group_create(self): + """ + Test that an Image Share Group can be created successfully. + """ + + with self.mock_post("/images/sharegroups/1234") as m: + sharegroup = self.client.sharegroups.create_sharegroup( + label="My Shared Images", + description="My group of images to share with my team.", + ) + + assert m.call_url == "/images/sharegroups" + + self.assertEqual( + m.call_data, + { + "label": "My Shared Images", + "description": "My group of images to share with my team.", + }, + ) + + self.assertEqual(sharegroup.id, 1234) + self.assertEqual( + sharegroup.description, + "My group of images to share with my team.", + ) + self.assertEqual(sharegroup.images_count, 0) + self.assertEqual(sharegroup.is_suspended, False) + self.assertEqual(sharegroup.label, "My Shared Images") + self.assertEqual(sharegroup.members_count, 0) + self.assertEqual( + sharegroup.uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_image_share_group_token_create(self): + """ + Test that an Image Share Group token can be created successfully. + """ + + with self.mock_post("/images/sharegroups/tokens/abc123") as m: + token = self.client.sharegroups.create_token( + label="My Sharegroup Token", + valid_for_sharegroup_uuid="e1d0e58b-f89f-4237-84ab-b82077342359", + ) + + assert m.call_url == "/images/sharegroups/tokens" + + self.assertEqual( + m.call_data, + { + "label": "My Sharegroup Token", + "valid_for_sharegroup_uuid": "e1d0e58b-f89f-4237-84ab-b82077342359", + }, + ) + + self.assertEqual(token[0].token_uuid, "abc123") + self.assertEqual(token[0].label, "My Sharegroup Token") + self.assertEqual(token[0].sharegroup_label, "A Sharegroup") + self.assertEqual( + token[0].sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual( + token[0].valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + self.assertEqual(token[0].status, "active") + self.assertEqual(token[1], "asupersecrettoken") diff --git a/test/unit/groups/linode_test.py b/test/unit/groups/linode_test.py index a495284fd..03278f03b 100644 --- a/test/unit/groups/linode_test.py +++ b/test/unit/groups/linode_test.py @@ -5,10 +5,7 @@ build_interface_options_vpc, ) -from linode_api4 import ( - InstancePlacementGroupAssignment, - InterfaceGeneration, -) +from linode_api4 import InstancePlacementGroupAssignment, InterfaceGeneration from linode_api4.objects import ConfigInterface diff --git a/test/unit/objects/image_share_group_test.py b/test/unit/objects/image_share_group_test.py new file mode 100644 index 000000000..e02f0672c --- /dev/null +++ b/test/unit/objects/image_share_group_test.py @@ -0,0 +1,295 @@ +from test.unit.base import ClientBaseCase + +from linode_api4.objects import ( + ImageShareGroup, + ImageShareGroupImagesToAdd, + ImageShareGroupImageToAdd, + ImageShareGroupImageToUpdate, + ImageShareGroupMemberToAdd, + ImageShareGroupMemberToUpdate, + ImageShareGroupToken, +) + + +class ImageShareGroupTest(ClientBaseCase): + """ + Tests the methods of ImageShareGroup class + """ + + def test_get_sharegroup(self): + """ + Tests that an Image Share Group is loaded correctly by ID + """ + sharegroup = ImageShareGroup(self.client, 1234) + + self.assertEqual(sharegroup.id, 1234) + self.assertEqual( + sharegroup.description, "My group of images to share with my team." + ) + self.assertEqual(sharegroup.images_count, 0) + self.assertEqual(sharegroup.is_suspended, False) + self.assertEqual(sharegroup.label, "My Shared Images") + self.assertEqual(sharegroup.members_count, 0) + self.assertEqual( + sharegroup.uuid, "1533863e-16a4-47b5-b829-ac0f35c13278" + ) + + def test_update_sharegroup(self): + """ + Tests that an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.label = "Updated Sharegroup Label" + sharegroup.description = "Updated description for my sharegroup." + sharegroup.save() + self.assertEqual(m.call_url, "/images/sharegroups/1234") + self.assertEqual( + m.call_data, + { + "label": "Updated Sharegroup Label", + "description": "Updated description for my sharegroup.", + }, + ) + + def test_delete_sharegroup(self): + """ + Tests that deleting an Image Share Group creates the correct api request + """ + with self.mock_delete() as m: + sharegroup = ImageShareGroup(self.client, 1234) + sharegroup.delete() + + self.assertEqual(m.call_url, "/images/sharegroups/1234") + + def test_add_images_to_sharegroup(self): + """ + Tests that Images can be added to an Image Share Group + """ + with self.mock_post("/images/sharegroups/1234/images") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.add_images( + ImageShareGroupImagesToAdd( + images=[ + ImageShareGroupImageToAdd(id="private/123"), + ] + ) + ) + + self.assertEqual(m.call_url, "/images/sharegroups/1234/images") + self.assertEqual( + m.call_data, + { + "images": [ + {"id": "private/123"}, + ] + }, + ) + + def test_get_image_shares_in_sharegroup(self): + """ + Tests that Image Shares in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/images") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + images = sharegroup.get_image_shares() + + self.assertEqual(m.call_url, "/images/sharegroups/1234/images") + self.assertEqual(len(images), 1) + self.assertEqual(images[0].id, "shared/1") + + def test_update_image_in_sharegroup(self): + """ + Tests that an Image shared in an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234/images/shared/1") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.update_image_share( + ImageShareGroupImageToUpdate(image_share_id="shared/1") + ) + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/images/shared/1" + ) + self.assertEqual( + m.call_data, + { + "image_share_id": "shared/1", + }, + ) + + def test_remove_image_from_sharegroup(self): + """ + Tests that an Image can be removed from an Image Share Group + """ + with self.mock_delete() as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.revoke_image_share("shared/1") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/images/shared/1" + ) + + def test_add_members_to_sharegroup(self): + """ + Tests that members can be added to an Image Share Group + """ + with self.mock_post("/images/sharegroups/1234/members") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.add_member( + ImageShareGroupMemberToAdd( + token="secrettoken", + label="New Member", + ) + ) + + self.assertEqual(m.call_url, "/images/sharegroups/1234/members") + self.assertEqual( + m.call_data, + { + "token": "secrettoken", + "label": "New Member", + }, + ) + + def test_get_members_in_sharegroup(self): + """ + Tests that members in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/members") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + members = sharegroup.get_members() + + self.assertEqual(m.call_url, "/images/sharegroups/1234/members") + self.assertEqual(len(members), 1) + self.assertEqual( + members[0].token_uuid, "4591075e-4ba8-43c9-a521-928c3d4a135d" + ) + + def test_get_member_in_sharegroup(self): + """ + Tests that a specific member in an Image Share Group can be retrieved + """ + with self.mock_get("/images/sharegroups/1234/members/abc123") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + member = sharegroup.get_member("abc123") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + self.assertEqual(member.token_uuid, "abc123") + + def test_update_member_in_sharegroup(self): + """ + Tests that a member in an Image Share Group can be updated + """ + with self.mock_put("/images/sharegroups/1234/members/abc123") as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.update_member( + ImageShareGroupMemberToUpdate( + token_uuid="abc123", + label="Updated Member Label", + ) + ) + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + self.assertEqual( + m.call_data, + { + "label": "Updated Member Label", + }, + ) + + def test_remove_member_from_sharegroup(self): + """ + Tests that a member can be removed from an Image Share Group + """ + with self.mock_delete() as m: + sharegroup = self.client.load(ImageShareGroup, 1234) + sharegroup.remove_member("abc123") + + self.assertEqual( + m.call_url, "/images/sharegroups/1234/members/abc123" + ) + + +class ImageShareGroupTokenTest(ClientBaseCase): + """ + Tests the methods of ImageShareGroupToken class + """ + + def test_get_sharegroup_token(self): + """ + Tests that an Image Share Group Token is loaded correctly by UUID + """ + token = self.client.load(ImageShareGroupToken, "abc123") + + self.assertEqual(token.token_uuid, "abc123") + self.assertEqual(token.label, "My Sharegroup Token") + self.assertEqual(token.sharegroup_label, "A Sharegroup") + self.assertEqual( + token.sharegroup_uuid, "e1d0e58b-f89f-4237-84ab-b82077342359" + ) + self.assertEqual(token.status, "active") + self.assertEqual( + token.valid_for_sharegroup_uuid, + "e1d0e58b-f89f-4237-84ab-b82077342359", + ) + + def test_update_sharegroup_token(self): + """ + Tests that an Image Share Group Token can be updated + """ + with self.mock_put("/images/sharegroups/tokens/abc123") as m: + token = self.client.load(ImageShareGroupToken, "abc123") + token.label = "Updated Token Label" + token.save() + self.assertEqual(m.call_url, "/images/sharegroups/tokens/abc123") + self.assertEqual( + m.call_data, + { + "label": "Updated Token Label", + }, + ) + + def test_delete_sharegroup_token(self): + """ + Tests that deleting an Image Share Group Token creates the correct api request + """ + with self.mock_delete() as m: + token = ImageShareGroupToken(self.client, "abc123") + token.delete() + + self.assertEqual(m.call_url, "/images/sharegroups/tokens/abc123") + + def test_sharegroup_token_get_sharegroup(self): + """ + Tests that the Image Share Group associated with a Token can be retrieved + """ + with self.mock_get("/images/sharegroups/tokens/abc123/sharegroup") as m: + token = self.client.load(ImageShareGroupToken, "abc123") + sharegroup = token.get_sharegroup() + + self.assertEqual( + m.call_url, "/images/sharegroups/tokens/abc123/sharegroup" + ) + self.assertEqual(sharegroup.id, 1234) + + def test_sharegroup_token_get_images(self): + """ + Tests that the Images associated with a Token can be retrieved + """ + with self.mock_get( + "/images/sharegroups/tokens/abc123/sharegroup/images" + ) as m: + token = self.client.load(ImageShareGroupToken, "abc123") + images = token.get_images() + + self.assertEqual( + m.call_url, + "/images/sharegroups/tokens/abc123/sharegroup/images", + ) + self.assertEqual(len(images), 1) + self.assertEqual(images[0].id, "shared/1") diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index 0869919d6..1ea2fd66e 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -55,6 +55,8 @@ def test_get_image(self): self.assertEqual(image.total_size, 1100) self.assertEqual(image.regions[0].region, "us-east") self.assertEqual(image.regions[0].status, "available") + self.assertEqual(image.is_shared, False) + self.assertIsNone(image.image_sharing) def test_image_create_upload(self): """ From 0442a8637e8d5a078a59e0e23b8d162e4aef7a0c Mon Sep 17 00:00:00 2001 From: rammanoj Date: Fri, 16 Jan 2026 14:37:27 -0500 Subject: [PATCH 359/379] Make NodePool optional for LKE-E in python sdk (#630) * make nodepools optional in cluster_create * Update linode_api4/groups/lke.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * address feedback --------- Co-authored-by: Erik Zilber Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/groups/lke.py | 11 +++- .../linode_client/test_linode_client.py | 2 +- test/integration/models/lke/test_lke.py | 10 ++-- test/unit/groups/lke_test.py | 52 ++++++++++++++++++- test/unit/linode_client_test.py | 4 +- test/unit/objects/lke_test.py | 4 +- 6 files changed, 71 insertions(+), 12 deletions(-) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index c3d6fdc5d..330c1d378 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -62,8 +62,8 @@ def cluster_create( self, region, label, - node_pools, kube_version, + node_pools: Optional[list] = None, control_plane: Union[ LKEClusterControlPlaneOptions, Dict[str, Any] ] = None, @@ -119,6 +119,15 @@ def cluster_create( :returns: The new LKE Cluster :rtype: LKECluster """ + if node_pools is None: + node_pools = [] + + if len(node_pools) == 0 and ( + tier is None or tier.lower() != "enterprise" + ): + raise ValueError( + "LKE standard clusters must have at least one node pool." + ) params = { "label": label, diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index eb1b06369..9935fc345 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -350,8 +350,8 @@ def test_fails_to_create_cluster_with_invalid_version(test_linode_client): cluster = client.lke.cluster_create( region, "example-cluster", - {"type": "g6-standard-1", "count": 3}, invalid_version, + {"type": "g6-standard-1", "count": 3}, ) except ApiError as e: assert "not valid" in str(e.json) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 71ebc1ff2..116665df6 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -38,7 +38,7 @@ def lke_cluster(test_linode_client): label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, node_pools ) yield cluster @@ -57,8 +57,8 @@ def lke_cluster_with_acl(test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, @@ -103,7 +103,7 @@ def lke_cluster_with_labels_and_taints(test_linode_client): label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, node_pools ) yield cluster @@ -124,8 +124,8 @@ def lke_cluster_with_apl(test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, control_plane=LKEClusterControlPlaneOptions( high_availability=True, ), @@ -160,8 +160,8 @@ def lke_cluster_enterprise(e2e_test_firewall, test_linode_client): cluster = test_linode_client.lke.cluster_create( region, label, - node_pools, version, + node_pools, tier="enterprise", ) diff --git a/test/unit/groups/lke_test.py b/test/unit/groups/lke_test.py index a39db81a6..802960192 100644 --- a/test/unit/groups/lke_test.py +++ b/test/unit/groups/lke_test.py @@ -21,8 +21,8 @@ def test_cluster_create_with_acl(self): self.client.lke.cluster_create( "us-mia", "test-acl-cluster", - [self.client.lke.node_pool("g6-nanode-1", 3)], "1.29", + [self.client.lke.node_pool("g6-nanode-1", 3)], control_plane=LKEClusterControlPlaneOptions( acl=LKEClusterControlPlaneACLOptions( enabled=True, @@ -41,3 +41,53 @@ def test_cluster_create_with_acl(self): assert m.call_data["control_plane"]["acl"]["addresses"]["ipv6"] == [ "1234::5678" ] + + def test_cluster_create_enterprise_without_node_pools(self): + """ + Tests that an enterprise LKE cluster can be created without node pools. + """ + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-west", + "test-enterprise-cluster", + "1.29", + tier="enterprise", + ) + + assert m.call_data["region"] == "us-west" + assert m.call_data["label"] == "test-enterprise-cluster" + assert m.call_data["k8s_version"] == "1.29" + assert m.call_data["tier"] == "enterprise" + assert m.call_data["node_pools"] == [] + + def test_cluster_create_enterprise_case_insensitive(self): + """ + Tests that tier comparison is case-insensitive for enterprise tier. + """ + with self.mock_post("lke/clusters") as m: + self.client.lke.cluster_create( + "us-west", + "test-enterprise-cluster", + "1.29", + tier="ENTERPRISE", + ) + + assert m.call_data["tier"] == "ENTERPRISE" + assert m.call_data["node_pools"] == [] + + def test_cluster_create_standard_without_node_pools_raises_error(self): + """ + Tests that creating a standard LKE cluster without node pools raises ValueError. + """ + with self.assertRaises(ValueError) as context: + self.client.lke.cluster_create( + "us-east", + "test-standard-cluster", + "1.29", + tier="standard", + ) + + self.assertIn( + "LKE standard clusters must have at least one node pool", + str(context.exception), + ) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index d87e08894..e82f3562d 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -817,7 +817,7 @@ def test_cluster_create_with_api_objects(self): node_pools = self.client.lke.node_pool(node_type, 3) with self.mock_post("lke/clusters") as m: cluster = self.client.lke.cluster_create( - region, "example-cluster", node_pools, version + region, "example-cluster", version, node_pools ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( @@ -850,8 +850,8 @@ def test_cluster_create_with_string_repr(self): cluster = self.client.lke.cluster_create( "ap-west", "example-cluster", - {"type": "g6-standard-1", "count": 3}, "1.19", + {"type": "g6-standard-1", "count": 3}, ) self.assertEqual(m.call_data["region"], "ap-west") self.assertEqual( diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index 10284a0c9..91f9ed3fe 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -302,6 +302,7 @@ def test_cluster_create_with_labels_and_taints(self): self.client.lke.cluster_create( "us-mia", "test-acl-cluster", + "1.29", [ self.client.lke.node_pool( "g6-nanode-1", @@ -317,7 +318,6 @@ def test_cluster_create_with_labels_and_taints(self): ], ) ], - "1.29", ) assert m.call_data["node_pools"][0] == { @@ -339,13 +339,13 @@ def test_cluster_create_with_apl(self): cluster = self.client.lke.cluster_create( "us-mia", "test-aapl-cluster", + "1.29", [ self.client.lke.node_pool( "g6-dedicated-4", 3, ) ], - "1.29", apl_enabled=True, control_plane=LKEClusterControlPlaneOptions( high_availability=True, From 7708f871b5b61edf7106e7c51a554eb1adf99abc Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:09:38 -0500 Subject: [PATCH 360/379] Remove non-existent doc links from AI imaginations. (#631) --- linode_api4/objects/lock.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py index b6552da7b..9cee64517 100644 --- a/linode_api4/objects/lock.py +++ b/linode_api4/objects/lock.py @@ -10,7 +10,7 @@ class LockType(StrEnum): """ LockType defines valid values for resource lock types. - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-lock + API Documentation: TBD """ cannot_delete = "cannot_delete" @@ -22,7 +22,7 @@ class LockEntity(JSONObject): """ Represents the entity that is locked. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + API Documentation: TBD """ id: int = 0 @@ -35,7 +35,7 @@ class Lock(Base): """ A resource lock that prevents deletion or modification of a resource. - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lock + API Documentation: TBD """ api_endpoint = "/locks/{id}" From 365c7d5380e6f24a04b9c58fdfe19153151433e6 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 5 Feb 2026 09:47:45 +0100 Subject: [PATCH 361/379] Regression fixes (#636) * Fix of: test_cluster_create_with_api_objects * Revert changes in lke.py, pass [node_pools] to cluster_create in int test instead * Rename node_pools to node_pool as it is single element --- test/integration/linode_client/test_linode_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 9935fc345..4060064d3 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -328,10 +328,10 @@ def test_cluster_create_with_api_objects(test_linode_client): node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] region = get_region(client, {"Kubernetes"}) - node_pools = client.lke.node_pool(node_type, 3) + node_pool = client.lke.node_pool(node_type, 3) label = get_test_label() - cluster = client.lke.cluster_create(region, label, node_pools, version) + cluster = client.lke.cluster_create(region, label, version, [node_pool]) assert cluster.region.id == region.id assert cluster.k8s_version.id == version.id From ce2a79ff7ebfe11aee65be792295776d186a2ef3 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 10 Feb 2026 09:13:45 -0500 Subject: [PATCH 362/379] Removed v4beta notices from Maintenance Policy (#643) * Removed v4beta notices from Maintenance Policy fields/methods * Fix lint --- linode_api4/groups/linode.py | 2 -- linode_api4/groups/maintenance.py | 2 -- linode_api4/objects/account.py | 6 ++---- linode_api4/objects/linode.py | 4 +--- test/integration/models/linode/test_linode.py | 3 +-- 5 files changed, 4 insertions(+), 13 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index f88808e64..e32a284f1 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -335,8 +335,6 @@ def instance_create( :type network_helper: bool :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. If not provided, the default policy (linode/migrate) will be applied. - NOTE: This field is in beta and may only - function if base_url is set to `https://api.linode.com/v4beta`. :type maintenance_policy: str :returns: A new Instance object, or a tuple containing the new Instance and diff --git a/linode_api4/groups/maintenance.py b/linode_api4/groups/maintenance.py index 7d56cec6e..63cb424df 100644 --- a/linode_api4/groups/maintenance.py +++ b/linode_api4/groups/maintenance.py @@ -9,8 +9,6 @@ class MaintenanceGroup(Group): def maintenance_policies(self): """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - Returns a collection of MaintenancePolicy objects representing available maintenance policies that can be applied to Linodes diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 54298ed11..a4aca1848 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -218,9 +218,7 @@ class AccountSettings(Base): "object_storage": Property(), "backups_enabled": Property(mutable=True), "interfaces_for_new_linodes": Property(mutable=True), - "maintenance_policy": Property( - mutable=True - ), # Note: This field is only available when using v4beta. + "maintenance_policy": Property(mutable=True), } @@ -249,7 +247,7 @@ class Event(Base): "duration": Property(), "secondary_entity": Property(), "message": Property(), - "maintenance_policy_set": Property(), # Note: This field is only available when using v4beta. + "maintenance_policy_set": Property(), "description": Property(), "source": Property(), "not_before": Property(is_datetime=True), diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index fae0926d5..1edf4e014 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -800,9 +800,7 @@ class Instance(Base): "lke_cluster_id": Property(), "capabilities": Property(unordered=True), "interface_generation": Property(), - "maintenance_policy": Property( - mutable=True - ), # Note: This field is only available when using v4beta. + "maintenance_policy": Property(mutable=True), "locks": Property(unordered=True), } diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index c485dd19c..574d5d9d2 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -1101,8 +1101,7 @@ def test_delete_interface_containing_vpc( def test_create_linode_with_maintenance_policy(test_linode_client): client = test_linode_client - # TODO: Replace with random region after GA - region = "ap-south" + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() policies = client.maintenance.maintenance_policies() From f67187addcb32f24a48da85283b7d545b8ea023d Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:43:24 -0500 Subject: [PATCH 363/379] Remove preview section from PR template (#638) Removed the preview section from the pull request template. --- .github/pull_request_template.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5bea77b2c..d97f93452 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,3 @@ **What are the steps to reproduce the issue or verify the changes?** **How do I run the relevant unit/integration tests?** - -## 📷 Preview - -**If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** \ No newline at end of file From f30ac54ad21217e0022d659c44ec088c84548b79 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:44:18 -0500 Subject: [PATCH 364/379] Add resource lock support for NodeBalancer (#637) --- linode_api4/objects/nodebalancer.py | 1 + test/fixtures/nodebalancers.json | 6 ++++-- test/fixtures/nodebalancers_123456.json | 3 +++ test/unit/objects/nodebalancers_test.py | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index f02dda269..cb6e566f7 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -252,6 +252,7 @@ class NodeBalancer(Base): "transfer": Property(), "tags": Property(mutable=True, unordered=True), "client_udp_sess_throttle": Property(mutable=True), + "locks": Property(unordered=True), } # create derived objects diff --git a/test/fixtures/nodebalancers.json b/test/fixtures/nodebalancers.json index 85eec186b..9b4dc8dae 100644 --- a/test/fixtures/nodebalancers.json +++ b/test/fixtures/nodebalancers.json @@ -10,7 +10,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123456", "client_conn_throttle": 0, - "tags": ["something"] + "tags": ["something"], + "locks": ["cannot_delete_with_subresources"] }, { "created": "2018-01-01T00:01:01", @@ -22,7 +23,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123457", "client_conn_throttle": 0, - "tags": [] + "tags": [], + "locks": [] } ], "results": 2, diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json index e965d4379..a78c8d3e3 100644 --- a/test/fixtures/nodebalancers_123456.json +++ b/test/fixtures/nodebalancers_123456.json @@ -10,5 +10,8 @@ "client_conn_throttle": 0, "tags": [ "something" + ], + "locks": [ + "cannot_delete_with_subresources" ] } \ No newline at end of file diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index ed0f0c320..c02b40ea3 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -175,6 +175,24 @@ def test_update(self): }, ) + def test_locks_not_in_put(self): + """ + Test that locks are not included in PUT request when updating a NodeBalancer. + Locks are managed through the separate /v4/locks endpoint. + """ + nb = NodeBalancer(self.client, 123456) + # Access locks to ensure it's loaded + self.assertEqual(nb.locks, ["cannot_delete_with_subresources"]) + + nb.label = "new-label" + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + # Verify locks is NOT in the PUT data + self.assertNotIn("locks", m.call_data) + self.assertEqual(m.call_data["label"], "new-label") + def test_firewalls(self): """ Test that you can get the firewalls for the requested NodeBalancer. From 8b8c61985730e08581cc736d7102ee41b98b9a33 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Wed, 11 Feb 2026 14:15:25 +0100 Subject: [PATCH 365/379] Align GHA workflows in the scope of report uploads (#642) * Add logic to upload test results for manual runs on demand only * Modify test_report_upload type to boolean * Set test_upload_report to choice type in e2e-test.yml --- .github/workflows/e2e-test-pr.yml | 10 +++++++++- .github/workflows/e2e-test.yml | 13 +++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 86809d177..f765b0a0d 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -27,6 +27,14 @@ on: pull_request_number: description: 'The number of the PR.' required: false + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' name: PR E2E Tests @@ -101,7 +109,7 @@ jobs: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - name: Upload test results - if: always() + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e2762ff95..5c24361d0 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -41,6 +41,14 @@ on: options: - 'true' - 'false' + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + type: choice + required: false + default: 'false' + options: + - 'true' + - 'false' push: branches: - main @@ -172,7 +180,8 @@ jobs: process-upload-report: runs-on: ubuntu-latest needs: [integration-tests] - if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + # Run even if integration tests fail on main repository AND push event OR test_report_upload is true in case of manual run + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) outputs: summary: ${{ steps.set-test-summary.outputs.summary }} @@ -271,4 +280,4 @@ jobs: payload: | channel: ${{ secrets.SLACK_CHANNEL_ID }} thread_ts: "${{ steps.main_message.outputs.ts }}" - text: "${{ needs.process-upload-report.outputs.summary }}" + text: "${{ needs.process-upload-report.outputs.summary }}" \ No newline at end of file From 6dc0564a4a0f3b83bf70cdb6c468d6b7725a9e35 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 12 Feb 2026 08:38:42 +0100 Subject: [PATCH 366/379] Fix test_lke_cluster_model_filter. Modify lke_cluster_instance fixture (#641) --- test/integration/filters/fixtures.py | 4 ++-- test/integration/filters/model_filters_test.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/integration/filters/fixtures.py b/test/integration/filters/fixtures.py index 31b7edcbf..e753236dd 100644 --- a/test/integration/filters/fixtures.py +++ b/test/integration/filters/fixtures.py @@ -25,11 +25,11 @@ def lke_cluster_instance(test_linode_client): region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) - node_pools = test_linode_client.lke.node_pool(node_type, 3) + node_pool = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, [node_pool] ) yield cluster diff --git a/test/integration/filters/model_filters_test.py b/test/integration/filters/model_filters_test.py index 22bb8299e..55bed6ac3 100644 --- a/test/integration/filters/model_filters_test.py +++ b/test/integration/filters/model_filters_test.py @@ -63,12 +63,13 @@ def test_linode_type_model_filter(test_linode_client): def test_lke_cluster_model_filter(test_linode_client, lke_cluster_instance): client = test_linode_client + lke_cluster = lke_cluster_instance filtered_cluster = client.lke.clusters( - LKECluster.label.contains(lke_cluster_instance.label) + LKECluster.label.contains(lke_cluster.label) ) - assert filtered_cluster[0].id == lke_cluster_instance.id + assert filtered_cluster[0].id == lke_cluster.id def test_networking_firewall_model_filter( From 4003f2af6ff0eeb608aacc39e57f68f3de7a95a2 Mon Sep 17 00:00:00 2001 From: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:23:16 +0100 Subject: [PATCH 367/379] Add region capability enum (#644) * python-sdk: Add region capability enum * python-sdk: Add region capability enum * Update linode_api4/objects/region.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/objects/__init__.py | 2 +- linode_api4/objects/region.py | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/__init__.py b/linode_api4/objects/__init__.py index 009e9436e..89a681635 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -3,7 +3,7 @@ from .dbase import DerivedBase from .serializable import JSONObject from .filtering import and_, or_ -from .region import Region +from .region import Region, Capability from .image import Image from .linode import * from .linode_interfaces import * diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index c9dc05099..3c8986259 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -3,6 +3,68 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects.base import Base, JSONObject, Property +from linode_api4.objects.serializable import StrEnum + + +class Capability(StrEnum): + """ + An enum class that represents the capabilities that Linode offers + across different regions and services. + + These capabilities indicate what services are available in each data center. + """ + + linodes = "Linodes" + nodebalancers = "NodeBalancers" + block_storage = "Block Storage" + object_storage = "Object Storage" + object_storage_regions = "Object Storage Access Key Regions" + object_storage_endpoint_types = "Object Storage Endpoint Types" + lke = "Kubernetes" + lke_ha_controlplanes = "LKE HA Control Planes" + lke_e = "Kubernetes Enterprise" + firewall = "Cloud Firewall" + gpu = "GPU Linodes" + vlans = "Vlans" + vpcs = "VPCs" + vpcs_extra = "VPCs Extra" + machine_images = "Machine Images" + dbaas = "Managed Databases" + dbaas_beta = "Managed Databases Beta" + bs_migrations = "Block Storage Migrations" + metadata = "Metadata" + premium_plans = "Premium Plans" + edge_plans = "Edge Plans" + distributed_plans = "Distributed Plans" + lke_control_plane_acl = "LKE Network Access Control List (IP ACL)" + aclb = "Akamai Cloud Load Balancer" + support_ticket_severity = "Support Ticket Severity" + backups = "Backups" + placement_group = "Placement Group" + disk_encryption = "Disk Encryption" + la_disk_encryption = "LA Disk Encryption" + akamai_ram_protection = "Akamai RAM Protection" + blockstorage_encryption = "Block Storage Encryption" + blockstorage_perf_b1 = "Block Storage Performance B1" + blockstorage_perf_b1_default = "Block Storage Performance B1 Default" + aclp = "Akamai Cloud Pulse" + aclp_logs = "Akamai Cloud Pulse Logs" + aclp_logs_lkee = "Akamai Cloud Pulse Logs LKE-E Audit" + aclp_logs_dc_lkee = "ACLP Logs Datacenter LKE-E" + smtp_enabled = "SMTP Enabled" + stackscripts = "StackScripts" + vpu = "NETINT Quadra T1U" + linode_interfaces = "Linode Interfaces" + maintenance_policy = "Maintenance Policy" + vpc_dual_stack = "VPC Dual Stack" + vpc_ipv6_stack = "VPC IPv6 Stack" + nlb = "Network LoadBalancer" + natgateway = "NAT Gateway" + lke_e_byovpc = "Kubernetes Enterprise BYO VPC" + lke_e_stacktype = "Kubernetes Enterprise Dual Stack" + ruleset = "Cloud Firewall Rule Set" + prefixlists = "Cloud Firewall Prefix Lists" + current_prefixlists = "Cloud Firewall Prefix List Current References" @dataclass From 5302e710789c259b9b2f6054d47579bf79ff0e17 Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Thu, 26 Feb 2026 08:46:29 +0100 Subject: [PATCH 368/379] Regression fixes (#647) * Add waits for Linodes to improve flaky tests * Increase timeout for Linode's 'offline' status * Revert import changes in test_account.py --- test/integration/models/account/test_account.py | 17 +++++++++++++---- test/integration/models/domain/test_domain.py | 4 ++-- test/integration/models/linode/test_linode.py | 6 ++++++ .../models/networking/test_networking.py | 9 ++++++--- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 5833a9344..4c4dcc134 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -1,7 +1,11 @@ import time from datetime import datetime from test.integration.conftest import get_region -from test.integration.helpers import get_test_label, retry_sending_request +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) import pytest @@ -102,13 +106,18 @@ def test_latest_get_event(test_linode_client, e2e_test_firewall): firewall=e2e_test_firewall, ) - events = client.load(Event, "") + def get_linode_status(): + return linode.status == "running" - latest_events = events._raw_json.get("data") + # To ensure the Linode is running and the 'event' key has been populated + wait_for_condition(3, 100, get_linode_status) + + events = client.load(Event, "") + latest_events = events._raw_json.get("data")[:15] linode.delete() - for event in latest_events[:15]: + for event in latest_events: if label == event["entity"]["label"]: break else: diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index 9dc180a6e..d7956d421 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -43,10 +43,10 @@ def test_clone(test_linode_client, test_domain): dom = "example.clone-" + timestamp + "-inttestsdk.org" domain.clone(dom) - ds = test_linode_client.domains() - time.sleep(1) + ds = test_linode_client.domains() + domains = [i.domain for i in ds] assert dom in domains diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 574d5d9d2..9f6194fa9 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -611,6 +611,12 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): migration_type=MigrationType.COLD, ) + def get_linode_status(): + return linode.status == "offline" + + # To verify that Linode's status changed before deletion (during migration status is set to 'offline') + wait_for_condition(5, 120, get_linode_status) + res = linode.delete() assert res diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 0edd5bd0a..27ffbb444 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -32,7 +32,7 @@ ) -def create_linode(test_linode_client): +def create_linode_func(test_linode_client): client = test_linode_client label = get_test_label() @@ -49,7 +49,7 @@ def create_linode(test_linode_client): @pytest.fixture def create_linode_for_ip_share(test_linode_client): - linode = create_linode(test_linode_client) + linode = create_linode_func(test_linode_client) yield linode @@ -58,7 +58,7 @@ def create_linode_for_ip_share(test_linode_client): @pytest.fixture def create_linode_to_be_shared_with_ips(test_linode_client): - linode = create_linode(test_linode_client) + linode = create_linode_func(test_linode_client) yield linode @@ -302,6 +302,8 @@ def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): wait_for_condition(3, 100, get_status, linode, "rebooting") assert linode.status == "rebooting" + wait_for_condition(3, 100, get_status, linode, "running") + # Delete the VLAN is_deleted = test_linode_client.networking.delete_vlan( vlan_label, linode.region @@ -334,6 +336,7 @@ def test_get_global_firewall_settings(test_linode_client): def test_ip_info(test_linode_client, create_linode): linode = create_linode + wait_for_condition(3, 100, get_status, linode, "running") ip_info = test_linode_client.load(IPAddress, linode.ipv4[0]) From 1fd5851129954a5a9e7d11cf771b5e97f23819c7 Mon Sep 17 00:00:00 2001 From: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:29:44 +0100 Subject: [PATCH 369/379] Deprecate `xen` from SDKs and Tools (#648) * Deprecate `xen` from SDKs and Tools --- linode_api4/objects/linode.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 1edf4e014..ccddd7e40 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,7 @@ import copy import string import sys +import warnings from dataclasses import dataclass, field from datetime import datetime from enum import Enum @@ -217,7 +218,7 @@ def resize(self, new_size): class Kernel(Base): """ The primary component of every Linux system. The kernel interfaces - with the system’s hardware and it controls the operating system’s core functionality. + with the system’s hardware, and it controls the operating system’s core functionality. Your Compute Instance is capable of running one of three kinds of kernels: @@ -237,6 +238,10 @@ class Kernel(Base): to compile the kernel from source than to download it from your package manager. For more information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS. + .. note:: + The ``xen`` property is deprecated and is no longer returned by the API. + It is maintained for backward compatibility only. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-kernel """ @@ -256,6 +261,16 @@ class Kernel(Base): "pvops": Property(), } + def __getattribute__(self, name: str) -> object: + if name == "xen": + warnings.warn( + "The 'xen' property of Kernel is deprecated and is no longer " + "returned by the API. It is maintained for backward compatibility only.", + DeprecationWarning, + stacklevel=2, + ) + return super().__getattribute__(name) + class Type(Base): """ From 90c287ae35505ec0c1a766de5111726ed8149def Mon Sep 17 00:00:00 2001 From: srbhaakamai Date: Thu, 26 Feb 2026 21:30:09 +0530 Subject: [PATCH 370/379] ACLP Alerting get_channles() API changes and Added Enum for 'Status' (#645) * Resolving requirement.txt conflicts * Added test with new Status and Alert Definition change * Revert "Resolving requirement.txt conflicts" This reverts commit d40b96c15ff00b89b29c941fae4d6379ed16f396. * introduced enums for alert status * Update linode_api4/objects/monitor.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed reviewer comments * fixed lint error * fix lint errors from make lint command * changed wait code to wait until its enabled --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linode_api4/objects/monitor.py | 50 ++++++++++++++++++- .../models/monitor/test_monitor.py | 4 +- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 4315e4c2e..ca8f83921 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -116,6 +116,19 @@ class DashboardType(StrEnum): custom = "custom" +class AlertStatus(StrEnum): + """ + Enum for supported alert status values. + """ + + AlertDefinitionStatusProvisioning = "provisioning" + AlertDefinitionStatusEnabling = "enabling" + AlertDefinitionStatusDisabling = "disabling" + AlertDefinitionStatusEnabled = "enabled" + AlertDefinitionStatusDisabled = "disabled" + AlertDefinitionStatusFailed = "failed" + + @dataclass class Filter(JSONObject): """ @@ -428,6 +441,40 @@ class ChannelContent(JSONObject): # Other channel types like 'webhook', 'slack' could be added here as Optional fields. +@dataclass +class EmailDetails(JSONObject): + """ + Represents email-specific details for an alert channel. + """ + + usernames: Optional[List[str]] = None + recipient_type: Optional[str] = None + + +@dataclass +class ChannelDetails(JSONObject): + """ + Represents the details block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailDetails] = None + + +@dataclass +class AlertInfo(JSONObject): + """ + Represents a reference to alerts associated with an alert channel. + Fields: + - url: str - API URL to fetch the alerts for this channel + - type: str - Type identifier (e.g., 'alerts-definitions') + - alert_count: int - Number of alerts associated with this channel + """ + + url: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + alert_count: int = 0 + + class AlertChannel(Base): """ Represents an alert channel used to deliver notifications when alerts @@ -450,7 +497,8 @@ class AlertChannel(Base): "label": Property(), "type": Property(), "channel_type": Property(), - "alerts": Property(mutable=False, json_object=Alerts), + "details": Property(mutable=False, json_object=ChannelDetails), + "alerts": Property(mutable=False, json_object=AlertInfo), "content": Property(mutable=False, json_object=ChannelContent), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index b6cf40b54..908ac1a44 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -16,6 +16,7 @@ MonitorService, MonitorServiceToken, ) +from linode_api4.objects.monitor import AlertStatus # List all dashboards @@ -227,7 +228,8 @@ def wait_for_alert_ready(alert_id, service_type: str): interval = initial_timeout alert = client.load(AlertDefinition, alert_id, service_type) while ( - getattr(alert, "status", None) == "in progress" + getattr(alert, "status", None) + != AlertStatus.AlertDefinitionStatusEnabled and (time.time() - start) < timeout ): time.sleep(interval) From 42ca199fe0200026fe0102c93e58dabbfb7118be Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:44:58 -0500 Subject: [PATCH 371/379] Update UDP Session Timeout Value in Test (#653) --- test/integration/models/nodebalancer/test_nodebalancer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 9e7537897..692efb027 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -130,7 +130,7 @@ def test_get_nb_config_with_udp(test_linode_client, create_nb_config_with_udp): assert "udp" == config.protocol assert 1234 == config.udp_check_port - assert 16 == config.udp_session_timeout + assert 2 == config.udp_session_timeout def test_update_nb_config(test_linode_client, create_nb_config_with_udp): From 362abdcb751a6271734f54f93a433e53ce4f2de9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:27:42 -0500 Subject: [PATCH 372/379] build(deps): bump crazy-max/ghaction-github-labeler from 5.3.0 to 6.0.0 (#659) Bumps [crazy-max/ghaction-github-labeler](https://github.com/crazy-max/ghaction-github-labeler) from 5.3.0 to 6.0.0. - [Release notes](https://github.com/crazy-max/ghaction-github-labeler/releases) - [Commits](https://github.com/crazy-max/ghaction-github-labeler/compare/24d110aa46a59976b8a7f35518cb7f14f434c916...548a7c3603594ec17c819e1239f281a3b801ab4d) --- updated-dependencies: - dependency-name: crazy-max/ghaction-github-labeler dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 843a41c4d..14e770b11 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v6 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 + uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml From bc5f7350247d7b3b06e9de8ac51ba8fcbeb18900 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:15:57 -0500 Subject: [PATCH 373/379] build(deps): bump actions/download-artifact from 7 to 8 (#657) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 5c24361d0..df1a41841 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -193,7 +193,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: test-report-file From 34fbbab7e8bc1dd5469e31e1d34202306c2136f0 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:58:32 -0500 Subject: [PATCH 374/379] Migrate from os.path to pathlib for file path handling (#654) * Migrate from os.path to pathlib for file path handling in multiple modules * fix --- docs/conf.py | 6 +++--- linode_api4/common.py | 8 ++++---- linode_api4/groups/linode.py | 7 ++++--- linode_api4/groups/profile.py | 8 ++++---- linode_api4/objects/nodebalancer.py | 12 +++++++----- 5 files changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index cd15307ac..ee6609943 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,11 +10,11 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. +# documentation root, use Path(...).absolute() to make it absolute, like shown here. # -import os import sys -sys.path.insert(0, os.path.abspath('..')) +from pathlib import Path +sys.path.insert(0, str(Path('..').absolute())) # -- Project information ----------------------------------------------------- diff --git a/linode_api4/common.py b/linode_api4/common.py index 7e98b1977..ac77d2a05 100644 --- a/linode_api4/common.py +++ b/linode_api4/common.py @@ -1,5 +1,5 @@ -import os from dataclasses import dataclass +from pathlib import Path from linode_api4.objects import JSONObject @@ -47,9 +47,9 @@ def load_and_validate_keys(authorized_keys): ret.append(k) else: # it doesn't appear to be a key.. is it a path to the key? - k = os.path.expanduser(k) - if os.path.isfile(k): - with open(k) as f: + k_path = Path(k).expanduser() + if k_path.is_file(): + with open(k_path) as f: ret.append(f.read().rstrip()) else: raise ValueError( diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index e32a284f1..2bd51fa97 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -1,5 +1,5 @@ import base64 -import os +from pathlib import Path from typing import Any, Dict, List, Optional, Union from linode_api4.common import load_and_validate_keys @@ -457,8 +457,9 @@ def stackscript_create( script_body = script if not script.startswith("#!"): # it doesn't look like a stackscript body, let's see if it's a file - if os.path.isfile(script): - with open(script) as f: + script_path = Path(script) + if script_path.is_file(): + with open(script_path) as f: script_body = f.read() else: raise ValueError( diff --git a/linode_api4/groups/profile.py b/linode_api4/groups/profile.py index 4c49a2b5a..ee583a1ac 100644 --- a/linode_api4/groups/profile.py +++ b/linode_api4/groups/profile.py @@ -1,5 +1,5 @@ -import os from datetime import datetime +from pathlib import Path from linode_api4 import UnexpectedResponseError from linode_api4.common import SSH_KEY_TYPES @@ -322,9 +322,9 @@ def ssh_key_upload(self, key, label): """ if not key.startswith(SSH_KEY_TYPES): # this might be a file path - look for it - path = os.path.expanduser(key) - if os.path.isfile(path): - with open(path) as f: + key_path = Path(key).expanduser() + if key_path.is_file(): + with open(key_path) as f: key = f.read().strip() if not key.startswith(SSH_KEY_TYPES): raise ValueError("Invalid SSH Public Key") diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index cb6e566f7..f70553295 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path from urllib import parse from linode_api4.common import Price, RegionPrice @@ -220,12 +220,14 @@ def load_ssl_data(self, cert_file, key_file): # we're disabling warnings here because these attributes are defined dynamically # through linode.objects.Base, and pylint isn't privy - if os.path.isfile(os.path.expanduser(cert_file)): - with open(os.path.expanduser(cert_file)) as f: + cert_path = Path(cert_file).expanduser() + if cert_path.is_file(): + with open(cert_path) as f: self.ssl_cert = f.read() - if os.path.isfile(os.path.expanduser(key_file)): - with open(os.path.expanduser(key_file)) as f: + key_path = Path(key_file).expanduser() + if key_path.is_file(): + with open(key_path) as f: self.ssl_key = f.read() From 26e964f7eccf9688e51348c0bb475f8a188ff5e8 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:32:02 -0400 Subject: [PATCH 375/379] project: Block Storage Volume Limit Increase (#635) * Support increased block storage volume limits * Fixed lint * Address CoPilot suggestions Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address more Copilot suggestions * Fix lint * Addressed PR comments * test (#629) --------- Co-authored-by: ezilber-akamai Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Vinay <143587840+vshanthe@users.noreply.github.com> Co-authored-by: Dawid <56120592+dawiddzhafarov@users.noreply.github.com> --- linode_api4/objects/linode.py | 17 ++- linode_api4/util.py | 26 ++++ .../models/volume/test_blockstorage.py | 40 ++++++ test/unit/util_test.py | 121 +++++++++++++++++- 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 test/integration/models/volume/test_blockstorage.py diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index ccddd7e40..3ffe4b232 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -40,9 +40,12 @@ from linode_api4.objects.serializable import JSONObject, StrEnum from linode_api4.objects.vpc import VPC, VPCSubnet from linode_api4.paginated_list import PaginatedList -from linode_api4.util import drop_null_keys +from linode_api4.util import drop_null_keys, generate_device_suffixes PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation +MIN_DEVICE_LIMIT = 8 +MB_PER_GB = 1024 +MAX_DEVICE_LIMIT = 64 class InstanceDiskEncryptionType(StrEnum): @@ -1272,9 +1275,19 @@ def config_create( from .volume import Volume # pylint: disable=import-outside-toplevel hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" + + device_limit = int( + max( + MIN_DEVICE_LIMIT, + min(self.specs.memory // MB_PER_GB, MAX_DEVICE_LIMIT), + ) + ) + device_names = [ - hypervisor_prefix + string.ascii_lowercase[i] for i in range(0, 8) + hypervisor_prefix + suffix + for suffix in generate_device_suffixes(device_limit) ] + device_map = { device_names[i]: None for i in range(0, len(device_names)) } diff --git a/linode_api4/util.py b/linode_api4/util.py index 1ddbcc25b..f661367af 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -2,6 +2,7 @@ Contains various utility functions. """ +import string from typing import Any, Dict @@ -27,3 +28,28 @@ def recursive_helper(value: Any) -> Any: return value return recursive_helper(data) + + +def generate_device_suffixes(n: int) -> list[str]: + """ + Generate n alphabetical suffixes starting with a, b, c, etc. + After z, continue with aa, ab, ac, etc. followed by aaa, aab, etc. + Example: + generate_device_suffixes(30) -> + ['a', 'b', 'c', ..., 'z', 'aa', 'ab', 'ac', 'ad'] + """ + letters = string.ascii_lowercase + result = [] + i = 0 + + while len(result) < n: + s = "" + x = i + while True: + s = letters[x % 26] + s + x = x // 26 - 1 + if x < 0: + break + result.append(s) + i += 1 + return result diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py new file mode 100644 index 000000000..8dac88e18 --- /dev/null +++ b/test/integration/models/volume/test_blockstorage.py @@ -0,0 +1,40 @@ +from test.integration.conftest import get_region +from test.integration.helpers import get_test_label, retry_sending_request + + +def test_config_create_with_extended_volume_limit(test_linode_client): + client = test_linode_client + + region = get_region(client, {"Linodes", "Block Storage"}, site_type="core") + label = get_test_label() + + linode, _ = client.linode.instance_create( + "g6-standard-6", + region, + image="linode/debian12", + label=label, + ) + + volumes = [ + client.volume_create( + f"{label}-vol-{i}", + region=region, + size=10, + ) + for i in range(12) + ] + + config = linode.config_create(volumes=volumes) + + devices = config._raw_json["devices"] + + assert len([d for d in devices.values() if d is not None]) == 12 + + assert "sdi" in devices + assert "sdj" in devices + assert "sdk" in devices + assert "sdl" in devices + + linode.delete() + for v in volumes: + retry_sending_request(3, v.delete) diff --git a/test/unit/util_test.py b/test/unit/util_test.py index 3123a4447..35adf38ff 100644 --- a/test/unit/util_test.py +++ b/test/unit/util_test.py @@ -1,6 +1,6 @@ import unittest -from linode_api4.util import drop_null_keys +from linode_api4.util import drop_null_keys, generate_device_suffixes class UtilTest(unittest.TestCase): @@ -53,3 +53,122 @@ def test_drop_null_keys_recursive(self): } assert drop_null_keys(value) == expected_output + + def test_generate_device_suffixes(self): + """ + Tests whether generate_device_suffixes works as expected. + """ + + expected_output_12 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + ] + assert generate_device_suffixes(12) == expected_output_12 + + expected_output_30 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "aa", + "ab", + "ac", + "ad", + ] + assert generate_device_suffixes(30) == expected_output_30 + + expected_output_60 = [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "aa", + "ab", + "ac", + "ad", + "ae", + "af", + "ag", + "ah", + "ai", + "aj", + "ak", + "al", + "am", + "an", + "ao", + "ap", + "aq", + "ar", + "as", + "at", + "au", + "av", + "aw", + "ax", + "ay", + "az", + "ba", + "bb", + "bc", + "bd", + "be", + "bf", + "bg", + "bh", + ] + assert generate_device_suffixes(60) == expected_output_60 From d18b54e1cf6bebd31f850c028b6646fb502de63f Mon Sep 17 00:00:00 2001 From: Dawid <56120592+dawiddzhafarov@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:20:42 +0100 Subject: [PATCH 376/379] Drop support for ScaleGrid databases (#649) --- linode_api4/objects/database.py | 115 ------------------ ...databases_mysql_instances_123_backups.json | 13 -- ...sql_instances_123_backups_456_restore.json | 1 - ...ases_postgresql_instances_123_backups.json | 13 -- ...sql_instances_123_backups_456_restore.json | 1 - test/unit/objects/database_test.py | 115 ------------------ 6 files changed, 258 deletions(-) delete mode 100644 test/fixtures/databases_mysql_instances_123_backups.json delete mode 100644 test/fixtures/databases_mysql_instances_123_backups_456_restore.json delete mode 100644 test/fixtures/databases_postgresql_instances_123_backups.json delete mode 100644 test/fixtures/databases_postgresql_instances_123_backups_456_restore.json diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index 979990e8e..b3c6f8c35 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,11 +1,8 @@ from dataclasses import dataclass, field from typing import Optional -from deprecated import deprecated - from linode_api4.objects import ( Base, - DerivedBase, JSONObject, MappedObject, Property, @@ -86,69 +83,6 @@ class DatabasePrivateNetwork(JSONObject): public_access: Optional[bool] = None -@deprecated( - reason="Backups are not supported for non-legacy database clusters." -) -class DatabaseBackup(DerivedBase): - """ - A generic Managed Database backup. - - This class is not intended to be used on its own. - Use the appropriate subclasses for the corresponding database engine. (e.g. MySQLDatabaseBackup) - """ - - api_endpoint = "" - derived_url_path = "backups" - parent_id_name = "database_id" - - properties = { - "created": Property(is_datetime=True), - "id": Property(identifier=True), - "label": Property(), - "type": Property(), - } - - def restore(self): - """ - Restore a backup to a Managed Database on your Account. - - API Documentation: - - - MySQL: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup-restore - - PostgreSQL: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup-restore - """ - - return self._client.post( - "{}/restore".format(self.api_endpoint), model=self - ) - - -@deprecated( - reason="Backups are not supported for non-legacy database clusters." -) -class MySQLDatabaseBackup(DatabaseBackup): - """ - A backup for an accessible Managed MySQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-mysql-instance-backup - """ - - api_endpoint = "/databases/mysql/instances/{database_id}/backups/{id}" - - -@deprecated( - reason="Backups are not supported for non-legacy database clusters." -) -class PostgreSQLDatabaseBackup(DatabaseBackup): - """ - A backup for an accessible Managed PostgreSQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/get-databases-postgresql-instance-backup - """ - - api_endpoint = "/databases/postgresql/instances/{database_id}/backups/{id}" - - @dataclass class MySQLDatabaseConfigMySQLOptions(JSONObject): """ @@ -296,7 +230,6 @@ class MySQLDatabase(Base): "id": Property(identifier=True), "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), - "backups": Property(derived_class=MySQLDatabaseBackup), "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), @@ -304,7 +237,6 @@ class MySQLDatabase(Base): "hosts": Property(), "port": Property(), "region": Property(), - "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), "type": Property(mutable=True), @@ -393,28 +325,6 @@ def patch(self): "{}/patch".format(MySQLDatabase.api_endpoint), model=self ) - @deprecated( - reason="Backups are not supported for non-legacy database clusters." - ) - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed MySQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-mysql-instance-backup - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(MySQLDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - def invalidate(self): """ Clear out cached properties. @@ -464,7 +374,6 @@ class PostgreSQLDatabase(Base): "id": Property(identifier=True), "label": Property(mutable=True), "allow_list": Property(mutable=True, unordered=True), - "backups": Property(derived_class=PostgreSQLDatabaseBackup), "cluster_size": Property(mutable=True), "created": Property(is_datetime=True), "encrypted": Property(), @@ -472,8 +381,6 @@ class PostgreSQLDatabase(Base): "hosts": Property(), "port": Property(), "region": Property(), - "replication_commit_type": Property(), - "replication_type": Property(), "ssl_connection": Property(), "status": Property(volatile=True), "type": Property(mutable=True), @@ -563,28 +470,6 @@ def patch(self): "{}/patch".format(PostgreSQLDatabase.api_endpoint), model=self ) - @deprecated( - reason="Backups are not supported for non-legacy database clusters." - ) - def backup_create(self, label, **kwargs): - """ - Creates a snapshot backup of a Managed PostgreSQL Database. - - API Documentation: https://techdocs.akamai.com/linode-api/reference/post-databases-postgre-sql-instance-backup - """ - - params = { - "label": label, - } - params.update(kwargs) - - self._client.post( - "{}/backups".format(PostgreSQLDatabase.api_endpoint), - model=self, - data=params, - ) - self.invalidate() - def invalidate(self): """ Clear out cached properties. diff --git a/test/fixtures/databases_mysql_instances_123_backups.json b/test/fixtures/databases_mysql_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_mysql_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_mysql_instances_123_backups_456_restore.json b/test/fixtures/databases_mysql_instances_123_backups_456_restore.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_mysql_instances_123_backups_456_restore.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_backups.json b/test/fixtures/databases_postgresql_instances_123_backups.json deleted file mode 100644 index 671c68826..000000000 --- a/test/fixtures/databases_postgresql_instances_123_backups.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "data": [ - { - "created": "2022-01-01T00:01:01", - "id": 456, - "label": "Scheduled - 02/04/22 11:11 UTC-XcCRmI", - "type": "auto" - } - ], - "page": 1, - "pages": 1, - "results": 1 -} \ No newline at end of file diff --git a/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json b/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json deleted file mode 100644 index 9e26dfeeb..000000000 --- a/test/fixtures/databases_postgresql_instances_123_backups_456_restore.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test/unit/objects/database_test.py b/test/unit/objects/database_test.py index 10cb8fc78..3d0eb4dad 100644 --- a/test/unit/objects/database_test.py +++ b/test/unit/objects/database_test.py @@ -116,63 +116,6 @@ def test_update(self): m.call_data["private_network"]["public_access"], True ) - def test_list_backups(self): - """ - Test that MySQL backups list properly - """ - - db = MySQLDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that MySQL database backups can be updated - """ - - with self.mock_post("/databases/mysql/instances/123/backups") as m: - db = MySQLDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="standby") - except Exception as e: - logger.warning( - "An error occurred while validating the request: %s", e - ) - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mysql/instances/123/backups" - ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "standby") - - def test_backup_restore(self): - """ - Test that MySQL database backups can be restored - """ - - with self.mock_post( - "/databases/mysql/instances/123/backups/456/restore" - ) as m: - db = MySQLDatabase(self.client, 123) - - db.backups[0].restore() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/mysql/instances/123/backups/456/restore" - ) - def test_patch(self): """ Test MySQL Database patching logic. @@ -383,64 +326,6 @@ def test_update(self): m.call_data["private_network"]["public_access"], True ) - def test_list_backups(self): - """ - Test that PostgreSQL backups list properly - """ - - db = PostgreSQLDatabase(self.client, 123) - backups = db.backups - - self.assertEqual(len(backups), 1) - - self.assertEqual(backups[0].id, 456) - self.assertEqual( - backups[0].label, "Scheduled - 02/04/22 11:11 UTC-XcCRmI" - ) - self.assertEqual(backups[0].type, "auto") - - def test_create_backup(self): - """ - Test that PostgreSQL database backups can be created - """ - - with self.mock_post("/databases/postgresql/instances/123/backups") as m: - db = PostgreSQLDatabase(self.client, 123) - - # We don't care about errors here; we just want to - # validate the request. - try: - db.backup_create("mybackup", target="standby") - except Exception as e: - logger.warning( - "An error occurred while validating the request: %s", e - ) - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, "/databases/postgresql/instances/123/backups" - ) - self.assertEqual(m.call_data["label"], "mybackup") - self.assertEqual(m.call_data["target"], "standby") - - def test_backup_restore(self): - """ - Test that PostgreSQL database backups can be restored - """ - - with self.mock_post( - "/databases/postgresql/instances/123/backups/456/restore" - ) as m: - db = PostgreSQLDatabase(self.client, 123) - - db.backups[0].restore() - - self.assertEqual(m.method, "post") - self.assertEqual( - m.call_url, - "/databases/postgresql/instances/123/backups/456/restore", - ) - def test_patch(self): """ Test PostgreSQL Database patching logic. From fa417d50d0d10c700c1322ea2684cb8f20225203 Mon Sep 17 00:00:00 2001 From: Michal Wojcik <32574975+mgwoj@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:15:35 +0100 Subject: [PATCH 377/379] python-sdk: Support regions/vpc-availability endpoints (#646) --- linode_api4/groups/region.py | 36 ++++- linode_api4/objects/region.py | 38 +++++ .../regions_us-east_vpc-availability.json | 5 + test/fixtures/regions_vpc-availability.json | 132 ++++++++++++++++++ test/integration/models/region/test_region.py | 62 ++++++++ test/unit/groups/region_test.py | 30 +++- test/unit/objects/region_test.py | 12 ++ 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/regions_us-east_vpc-availability.json create mode 100644 test/fixtures/regions_vpc-availability.json create mode 100644 test/integration/models/region/test_region.py diff --git a/linode_api4/groups/region.py b/linode_api4/groups/region.py index baf8697e4..54bb37f0d 100644 --- a/linode_api4/groups/region.py +++ b/linode_api4/groups/region.py @@ -1,6 +1,9 @@ from linode_api4.groups import Group from linode_api4.objects import Region -from linode_api4.objects.region import RegionAvailabilityEntry +from linode_api4.objects.region import ( + RegionAvailabilityEntry, + RegionVPCAvailability, +) class RegionGroup(Group): @@ -43,3 +46,34 @@ def availability(self, *filters): return self.client._get_and_filter( RegionAvailabilityEntry, *filters, endpoint="/regions/availability" ) + + def vpc_availability(self, *filters): + """ + Returns VPC availability data for all regions. + + NOTE: IPv6 VPCs may not currently be available to all users. + + This endpoint supports pagination with the following parameters: + - page: Page number (>= 1) + - page_size: Number of items per page (25-500) + + Pagination is handled automatically by PaginatedList. To configure page_size, + set it when creating the LinodeClient: + + client = LinodeClient(token, page_size=100) + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-vpc-availability + + :param filters: Any number of filters to apply to this query. + See :doc:`Filtering Collections` + for more details on filtering. + + :returns: A list of VPC availability data for regions. + :rtype: PaginatedList of RegionVPCAvailability + """ + + return self.client._get_and_filter( + RegionVPCAvailability, + *filters, + endpoint="/regions/vpc-availability", + ) diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index 3c8986259..9a77dc485 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -125,6 +125,29 @@ def availability(self) -> List["RegionAvailabilityEntry"]: return [RegionAvailabilityEntry.from_json(v) for v in result] + @property + def vpc_availability(self) -> "RegionVPCAvailability": + """ + Returns VPC availability data for this region. + + NOTE: IPv6 VPCs may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-region-vpc-availability + + :returns: VPC availability data for this region. + :rtype: RegionVPCAvailability + """ + result = self._client.get( + f"{self.api_endpoint}/vpc-availability", model=self + ) + + if result is None: + raise UnexpectedResponseError( + "Expected VPC availability data, got None." + ) + + return RegionVPCAvailability.from_json(result) + @dataclass class RegionAvailabilityEntry(JSONObject): @@ -137,3 +160,18 @@ class RegionAvailabilityEntry(JSONObject): region: Optional[str] = None plan: Optional[str] = None available: bool = False + + +@dataclass +class RegionVPCAvailability(JSONObject): + """ + Represents the VPC availability data for a region. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-regions-vpc-availability + + NOTE: IPv6 VPCs may not currently be available to all users. + """ + + region: Optional[str] = None + available: bool = False + available_ipv6_prefix_lengths: Optional[List[int]] = None diff --git a/test/fixtures/regions_us-east_vpc-availability.json b/test/fixtures/regions_us-east_vpc-availability.json new file mode 100644 index 000000000..209959e5d --- /dev/null +++ b/test/fixtures/regions_us-east_vpc-availability.json @@ -0,0 +1,5 @@ +{ + "region": "us-east", + "available": true, + "available_ipv6_prefix_lengths": [52, 48] +} diff --git a/test/fixtures/regions_vpc-availability.json b/test/fixtures/regions_vpc-availability.json new file mode 100644 index 000000000..5e4d386df --- /dev/null +++ b/test/fixtures/regions_vpc-availability.json @@ -0,0 +1,132 @@ +{ + "data": [ + { + "region": "us-east", + "available": true, + "available_ipv6_prefix_lengths": [52, 48] + }, + { + "region": "us-west", + "available": true, + "available_ipv6_prefix_lengths": [56, 52, 48] + }, + { + "region": "nl-ams", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-ord", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-iad", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "fr-par", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-sea", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "br-gru", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "se-sto", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "es-mad", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "in-maa", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "jp-osa", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "it-mil", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-mia", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "id-cgk", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "us-lax", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "gb-lon", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "au-mel", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "in-bom-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "de-fra-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "sg-sin-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "jp-tyo-3", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "fr-par-2", + "available": true, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "ca-central", + "available": false, + "available_ipv6_prefix_lengths": [] + }, + { + "region": "ap-southeast", + "available": false, + "available_ipv6_prefix_lengths": [] + } + ], + "page": 1, + "pages": 2, + "results": 50 +} diff --git a/test/integration/models/region/test_region.py b/test/integration/models/region/test_region.py new file mode 100644 index 000000000..d9d4006a7 --- /dev/null +++ b/test/integration/models/region/test_region.py @@ -0,0 +1,62 @@ +import pytest + +from linode_api4.objects import Region + + +@pytest.mark.smoke +def test_list_regions_vpc_availability(test_linode_client): + """ + Test listing VPC availability for all regions. + """ + client = test_linode_client + + vpc_availability = client.regions.vpc_availability() + + assert len(vpc_availability) > 0 + + for entry in vpc_availability: + assert entry.region is not None + assert len(entry.region) > 0 + assert entry.available is not None + assert isinstance(entry.available, bool) + # available_ipv6_prefix_lengths may be empty list but should exist + assert entry.available_ipv6_prefix_lengths is not None + assert isinstance(entry.available_ipv6_prefix_lengths, list) + + +@pytest.mark.smoke +def test_get_region_vpc_availability_via_object(test_linode_client): + """ + Test getting VPC availability via the Region object property. + """ + client = test_linode_client + + # Get the first available region + regions = client.regions() + assert len(regions) > 0 + test_region_id = regions[0].id + + region = Region(client, test_region_id) + vpc_avail = region.vpc_availability + + assert vpc_avail is not None + assert vpc_avail.region == test_region_id + assert vpc_avail.available is not None + assert isinstance(vpc_avail.available, bool) + assert vpc_avail.available_ipv6_prefix_lengths is not None + assert isinstance(vpc_avail.available_ipv6_prefix_lengths, list) + + +def test_vpc_availability_available_regions(test_linode_client): + """ + Test that some regions have VPC availability enabled. + """ + client = test_linode_client + + vpc_availability = client.regions.vpc_availability() + + # Filter for regions where VPC is available + available_regions = [v for v in vpc_availability if v.available] + + # There should be at least some regions with VPC available + assert len(available_regions) > 0 diff --git a/test/unit/groups/region_test.py b/test/unit/groups/region_test.py index fe44c13ab..35826c534 100644 --- a/test/unit/groups/region_test.py +++ b/test/unit/groups/region_test.py @@ -25,10 +25,7 @@ def test_list_availability(self): for entry in avail_entries: assert entry.region is not None assert len(entry.region) > 0 - - assert entry.plan is not None assert len(entry.plan) > 0 - assert entry.available is not None # Ensure all three pages are read @@ -49,3 +46,30 @@ def test_list_availability(self): assert json.loads(call.get("headers").get("X-Filter")) == { "+and": [{"region": "us-east"}, {"plan": "premium4096.7"}] } + + def test_list_vpc_availability(self): + """ + Tests that region VPC availability can be listed. + """ + + with self.mock_get("/regions/vpc-availability") as m: + vpc_entries = self.client.regions.vpc_availability() + + assert len(vpc_entries) > 0 + + for entry in vpc_entries: + assert len(entry.region) > 0 + assert entry.available is not None + # available_ipv6_prefix_lengths may be empty list but should exist + assert entry.available_ipv6_prefix_lengths is not None + + # Ensure both pages are read + assert m.call_count == 2 + assert ( + m.mock.call_args_list[0].args[0] == "//regions/vpc-availability" + ) + + assert ( + m.mock.call_args_list[1].args[0] + == "//regions/vpc-availability?page=2&page_size=25" + ) diff --git a/test/unit/objects/region_test.py b/test/unit/objects/region_test.py index 73fdc8f5d..7bc3ae9f8 100644 --- a/test/unit/objects/region_test.py +++ b/test/unit/objects/region_test.py @@ -49,3 +49,15 @@ def test_region_availability(self): assert len(entry.plan) > 0 assert entry.available is not None + + def test_region_vpc_availability(self): + """ + Tests that VPC availability for a specific region can be retrieved. + """ + vpc_avail = Region(self.client, "us-east").vpc_availability + + assert vpc_avail is not None + assert vpc_avail.region == "us-east" + assert vpc_avail.available is True + assert vpc_avail.available_ipv6_prefix_lengths is not None + assert isinstance(vpc_avail.available_ipv6_prefix_lengths, list) From cdfcd3dc821e94a5580e7b2b08d2c7a458889a18 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:36:06 -0400 Subject: [PATCH 378/379] Fix invalid error assertion in VPC integration tests (#667) --- test/integration/models/vpc/test_vpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/models/vpc/test_vpc.py b/test/integration/models/vpc/test_vpc.py index ee35929b0..85d32d858 100644 --- a/test/integration/models/vpc/test_vpc.py +++ b/test/integration/models/vpc/test_vpc.py @@ -105,7 +105,7 @@ def test_fails_update_subnet_invalid_data(create_vpc_with_subnet): subnet.save() assert excinfo.value.status == 400 - assert "Label must include only ASCII" in str(excinfo.value.json) + assert "Must only use ASCII" in str(excinfo.value.json) def test_fails_create_subnet_with_invalid_ipv6_range(create_vpc): From c10fadcab83c0163468cc40b59be237916c4e152 Mon Sep 17 00:00:00 2001 From: Dawid <56120592+dawiddzhafarov@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:01:50 +0100 Subject: [PATCH 379/379] TPT-4213: Fix assertion for database engine config integration test (#662) * Fix assertion for database engine config integration test * TPT-4213 Add explanation to commented out assertion --- test/integration/models/database/test_database.py | 8 +++++++- .../models/database/test_database_engine_config.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/test/integration/models/database/test_database.py b/test/integration/models/database/test_database.py index dbb763c55..7092eca06 100644 --- a/test/integration/models/database/test_database.py +++ b/test/integration/models/database/test_database.py @@ -230,6 +230,9 @@ def test_update_sql_db(test_linode_client, test_create_sql_db): assert res assert database.allow_list == new_allow_list + # Label assertion is commented out because the API updates + # the label intermittently, causing test failures. The issue + # is tracked in TPT-4268. # assert database.label == label assert database.updates.day_of_week == 2 @@ -354,7 +357,10 @@ def test_update_postgres_db(test_linode_client, test_create_postgres_db): assert res assert database.allow_list == new_allow_list - assert database.label == label + # Label assertion is commented out because the API updates + # the label intermittently, causing test failures. The issue + # is tracked in TPT-4268. + # assert database.label == label assert database.updates.day_of_week == 2 diff --git a/test/integration/models/database/test_database_engine_config.py b/test/integration/models/database/test_database_engine_config.py index 446281a2d..184b63522 100644 --- a/test/integration/models/database/test_database_engine_config.py +++ b/test/integration/models/database/test_database_engine_config.py @@ -100,7 +100,7 @@ def test_get_mysql_config(test_linode_client): assert isinstance(brp, dict) assert brp["type"] == "integer" assert brp["minimum"] == 600 - assert brp["maximum"] == 86400 + assert brp["maximum"] == 9007199254740991 assert brp["requires_restart"] is False # mysql sub-keys