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 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 From c77ec6d8e7e1b0a235e593ed23e410e596ce919d Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 25 Mar 2026 09:03:42 -0400 Subject: [PATCH 07/28] TPT-3809: Added DiskEncryption field for LKE Node Pool creation (#670) * Added DiskEncryption field for LKE Node Pool creation * Address CoPilot suggestions --- linode_api4/objects/lke.py | 8 ++++++++ test/integration/models/lke/test_lke.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 0864052f1..aa506a606 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -8,6 +8,7 @@ Base, DerivedBase, Instance, + InstanceDiskEncryptionType, JSONObject, MappedObject, Property, @@ -422,6 +423,9 @@ def node_pool_create( ] = None, update_strategy: Optional[str] = None, label: str = None, + disk_encryption: Optional[ + Union[str, InstanceDiskEncryptionType] + ] = None, **kwargs, ): """ @@ -443,6 +447,9 @@ def node_pool_create( :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 disk_encryption: Local disk encryption setting for this LKE node pool. + One of 'enabled' or 'disabled'. Defaults to 'disabled'. + :type disk_encryption: str or InstanceDiskEncryptionType :param kwargs: Any other arguments to pass to the API. See the API docs for possible values. @@ -459,6 +466,7 @@ def node_pool_create( "taints": taints, "k8s_version": k8s_version, "update_strategy": update_strategy, + "disk_encryption": disk_encryption, } params.update(kwargs) diff --git a/test/integration/models/lke/test_lke.py b/test/integration/models/lke/test_lke.py index 116665df6..96ab1d3cc 100644 --- a/test/integration/models/lke/test_lke.py +++ b/test/integration/models/lke/test_lke.py @@ -211,6 +211,21 @@ def _to_comparable(p: LKENodePool) -> Dict[str, Any]: ) +def test_node_pool_create_with_disk_encryption(test_linode_client, lke_cluster): + node_type = test_linode_client.linode.types()[1] + + pool = lke_cluster.node_pool_create( + node_type, + 1, + disk_encryption=InstanceDiskEncryptionType.enabled, + ) + + try: + assert pool.disk_encryption == InstanceDiskEncryptionType.enabled + finally: + pool.delete() + + def test_cluster_dashboard_url_view(lke_cluster): cluster = lke_cluster From c9e18a599240a331fed5aa104102226d754ce551 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:25:18 -0400 Subject: [PATCH 08/28] TPT-4318: Add @linode/dx-sdets to CODEOWNERS (#671) * TPT-4318: Add @linode/dx-sdets to CODEOWNERS * Correct CODEOWNERS --- CODEOWNERS | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 69cb641ca..e023b0d14 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1 @@ -* @linode/dx - +* @linode/dx @linode/dx-sdets From d692eefefaa9b1e7d6866e641ba5f832b48e53c6 Mon Sep 17 00:00:00 2001 From: Lena Garber <114949949+lgarber-akamai@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:03:39 -0400 Subject: [PATCH 09/28] TPT-4324: Allow dict passthrough for config_create 'devices' field; update repo to reflect 3.9 EOL (#673) * Allow dict passthrough for config_create 'devices' field * Add negative tests * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * tidy up --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/e2e-test.yml | 4 +- linode_api4/objects/linode.py | 149 ++++++++++++------ linode_api4/util.py | 9 +- pyproject.toml | 7 +- test/integration/models/linode/test_linode.py | 9 ++ .../models/volume/test_blockstorage.py | 27 ++++ test/unit/objects/linode_test.py | 41 +++++ 8 files changed, 189 insertions(+), 59 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd8eeea17..a9b5e4336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index df1a41841..1298af319 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -55,8 +55,8 @@ on: - dev env: - DEFAULT_PYTHON_VERSION: "3.10" - EOL_PYTHON_VERSION: "3.9" + DEFAULT_PYTHON_VERSION: "3.13" + EOL_PYTHON_VERSION: "3.10" EXIT_STATUS: 0 jobs: diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 3ffe4b232..9b76668e3 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import copy import string import sys @@ -40,7 +42,11 @@ 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, generate_device_suffixes +from linode_api4.util import ( + drop_null_keys, + generate_device_suffixes, + normalize_as_list, +) PASSWORD_CHARS = string.ascii_letters + string.digits + string.punctuation MIN_DEVICE_LIMIT = 8 @@ -1246,14 +1252,14 @@ def _func(value): # create derived objects def config_create( self, - kernel=None, - label=None, - devices=[], - disks=[], - volumes=[], - interfaces=[], + kernel: Kernel | str | None = None, + label: str | None = None, + devices: "Disk | Volume | dict[str, Any] | list[Disk | Volume | dict[str, Any]] | None" = None, + disks: Disk | int | list[Disk | int] | None = None, + volumes: "Volume | int | list[Volume | int] | None" = None, + interfaces: list[ConfigInterface | dict[str, Any]] | None = None, **kwargs, - ): + ) -> Config: """ Creates a Linode Config with the given attributes. @@ -1263,10 +1269,13 @@ def config_create( :param label: The config label :param disks: The list of disks, starting at sda, to map to this config. :param volumes: The volumes, starting after the last disk, to map to this - config + config. :param devices: A list of devices to assign to this config, in device - index order. Values must be of type Disk or Volume. If this is - given, you may not include disks or volumes. + index order, a raw device mapping dict to pass directly to the API + (e.g. ``{"sda": {"disk_id": 123}, "sdb": Volume(...)}``), or + a single Disk or Volume. + If this is given, you may not include disks or volumes. + :param interfaces: A list of ConfigInterface objects or dicts to assign to this config. :param **kwargs: Any other arguments accepted by the api. :returns: A new Linode Config @@ -1274,6 +1283,8 @@ def config_create( # needed here to avoid circular imports from .volume import Volume # pylint: disable=import-outside-toplevel + interfaces = [] if interfaces is None else interfaces + hypervisor_prefix = "sd" if self.hypervisor == "kvm" else "xvd" device_limit = int( @@ -1288,52 +1299,83 @@ def config_create( for suffix in generate_device_suffixes(device_limit) ] - device_map = { - device_names[i]: None for i in range(0, len(device_names)) - } + def _flatten_device(device: Disk | Volume | dict | None): + if device is None: + return None + elif isinstance(device, Disk): + return {"disk_id": device.id} + elif isinstance(device, Volume): + return {"volume_id": device.id} + elif isinstance(device, dict): + return device + + raise TypeError("Disk, Volume, or dict expected!") + + def _device_entry(device: Disk | Volume | int, key: str): + if isinstance(device, (Disk, Volume)): + return _flatten_device(device) + + try: + device_id = int(device) + except (TypeError, ValueError): + raise TypeError( + "Disk, Volume, or integer ID expected!" + ) from None + + return {key: device_id} + + def _build_devices(): + # Devices is a dict, flatten and pass through + if isinstance(devices, dict): + return { + k: ( + _flatten_device(v) + if isinstance(v, (Disk, Volume)) + else v + ) + for k, v in devices.items() + } + device_list = [] + + if devices: + device_list += [ + _flatten_device(device) + for device in normalize_as_list(devices) + ] + + if disks: + device_list += [ + _device_entry(disk, "disk_id") if disk is not None else None + for disk in normalize_as_list(disks) + ] + + if volumes: + device_list += [ + ( + _device_entry(volume, "volume_id") + if volume is not None + else None + ) + for volume in normalize_as_list(volumes) + ] + + return { + device_names[i]: device for i, device in enumerate(device_list) + } + + # This validation is enforced for backwards compatibility but isn't + # technically needed anymore if devices and (disks or volumes): 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): - disks = [disks] - if not isinstance(volumes, list): - volumes = [volumes] - - devices = [] - - for d in disks: - if d is None: - devices.append(None) - elif isinstance(d, Disk): - devices.append(d) - else: - devices.append(Disk(self._client, int(d), self.id)) - - for v in volumes: - if v is None: - devices.append(None) - elif isinstance(v, Volume): - devices.append(v) - else: - devices.append(Volume(self._client, int(v))) - - if not devices: - raise ValueError("Must include at least one disk or volume!") + device_map = _build_devices() - for i, d in enumerate(devices): - if d is None: - pass - elif isinstance(d, Disk): - device_map[device_names[i]] = {"disk_id": d.id} - elif isinstance(d, Volume): - device_map[device_names[i]] = {"volume_id": d.id} - else: - raise TypeError("Disk or Volume expected!") + if len(device_map) < 1: + raise ValueError("Must include at least one disk or volume!") param_interfaces = [] for interface in interfaces: @@ -1845,8 +1887,8 @@ def clone( to_linode=None, region=None, instance_type=None, - configs=[], - disks=[], + configs=None, + disks=None, label=None, group=None, with_backups=None, @@ -1902,7 +1944,10 @@ def clone( 'You may only specify one of "to_linode" and "region"' ) - if region and not type: + configs = [] if configs is None else configs + disks = [] if disks is None else disks + + if region and not instance_type: raise ValueError('Specifying a region requires a "service" as well') if not isinstance(configs, list) and not isinstance( diff --git a/linode_api4/util.py b/linode_api4/util.py index f661367af..0ba6b8e09 100644 --- a/linode_api4/util.py +++ b/linode_api4/util.py @@ -3,7 +3,7 @@ """ import string -from typing import Any, Dict +from typing import Any, Dict, List, Tuple, Union def drop_null_keys(data: Dict[Any, Any], recursive=True) -> Dict[Any, Any]: @@ -30,6 +30,13 @@ def recursive_helper(value: Any) -> Any: return recursive_helper(data) +def normalize_as_list(value: Any) -> Union[List, Tuple]: + """ + Returns the value wrapped in a list if it isn't already a list or tuple. + """ + return value if isinstance(value, (list, tuple)) else [value] + + def generate_device_suffixes(n: int) -> list[str]: """ Generate n alphabetical suffixes starting with a, b, c, etc. diff --git a/pyproject.toml b/pyproject.toml index 4d8542cfa..7f3129d39 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.9" +requires-python = ">=3.10" keywords = [ "akamai", "Akamai Connected Cloud", @@ -25,10 +25,11 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dependencies = ["requests", "polling", "deprecated"] dynamic = ["version"] @@ -78,7 +79,7 @@ line_length = 80 [tool.black] line-length = 80 -target-version = ["py38", "py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312", "py313", "py314"] [tool.autoflake] expand-star-imports = true diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 9f6194fa9..512b6c513 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -790,6 +790,15 @@ def test_get_config(test_linode_client, create_linode): assert config.id == linode.configs[0].id +def test_config_create_without_devices_raises_error(create_linode): + linode = create_linode + + with pytest.raises(ValueError) as err: + linode.config_create(label="test-config-no-devices") + + assert "Must include at least one disk or volume!" in str(err.value) + + def test_get_linode_types(test_linode_client): types = test_linode_client.linode.types() diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py index 8dac88e18..e382f4a2a 100644 --- a/test/integration/models/volume/test_blockstorage.py +++ b/test/integration/models/volume/test_blockstorage.py @@ -38,3 +38,30 @@ def test_config_create_with_extended_volume_limit(test_linode_client): linode.delete() for v in volumes: retry_sending_request(3, v.delete) + + +def test_config_create_with_device_map(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, + ) + + disk_id = linode.disks[0].id + devices = { + "sdl": {"disk_id": disk_id}, + } + + config = linode.config_create(label=f"{label}-config", devices=devices) + + result_devices = config._raw_json["devices"] + assert result_devices["sdl"] is not None + assert result_devices["sdl"]["disk_id"] == disk_id + + linode.delete() diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 40bbb5069..1c31f8109 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -459,6 +459,47 @@ def test_create_disk(self): assert disk.id == 12345 assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_create_config_with_device_map(self): + """ + Tests that config_create passes through a raw device map unchanged. + """ + linode = Instance(self.client, 123) + devices = { + "sda": {"disk_id": 111}, + "sdb": {"volume_id": 222}, + "sdc": None, + } + + with self.mock_post( + {"id": 456, "devices": devices, "interfaces": []} + ) as m: + config = linode.config_create(label="test-config", devices=devices) + + self.assertEqual(m.call_url, "/linode/instances/123/configs") + self.assertEqual( + m.call_data, + { + "label": "test-config", + "devices": devices, + "interfaces": [], + }, + ) + + self.assertEqual(config.id, 456) + + def test_create_config_without_devices_raises_error(self): + """ + Tests that config_create raises ValueError when no devices, disks, or volumes are specified. + """ + linode = Instance(self.client, 123) + + with self.assertRaises(ValueError) as context: + linode.config_create(label="test-config") + + assert "Must include at least one disk or volume!" in str( + context.exception + ) + def test_get_placement_group(self): """ Tests that you can get the placement group for a Linode From 47e620ed2e4480ae96de910c2256fbf954a3e024 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Thu, 2 Apr 2026 08:58:29 -0400 Subject: [PATCH 10/28] TPT-4298: Added PR title checking to lint workflow and clean up release notes workflow (#678) * Added PR title checking to lint workflow and clean up release notes * Bumped actions/github-script from v7 to v8 --- .github/workflows/ci.yml | 28 +++++++++++++++++ .github/workflows/clean-release-notes.yml | 37 +++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .github/workflows/clean-release-notes.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9b5e4336..565cad0c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,35 @@ on: jobs: lint: runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read steps: + # Enforce TPT-1234: prefix on PR titles, with the following exemptions: + # - PRs labeled 'dependencies' (e.g. Dependabot PRs) + # - PRs labeled 'hotfix' (urgent fixes that may not have a ticket) + # - PRs labeled 'community-contribution' (external contributors without TPT tickets) + # - PRs labeled 'ignore-for-release' (release PRs that don't need a ticket prefix) + - name: Validate PR Title + if: github.event_name == 'pull_request' + uses: amannn/action-semantic-pull-request@v6 + with: + types: | + TPT-\d+ + requireScope: false + # Override the default header pattern to allow hyphens and digits in the type + # (e.g. "TPT-4298: Description"). The default pattern only matches word + # characters (\w) which excludes hyphens. + headerPattern: '^([\w-]+):\s?(.*)$' + headerPatternCorrespondence: type, subject + ignoreLabels: | + dependencies + hotfix + community-contribution + ignore-for-release + env: + GITHUB_TOKEN: ${{ github.token }} + - name: checkout repo uses: actions/checkout@v6 diff --git a/.github/workflows/clean-release-notes.yml b/.github/workflows/clean-release-notes.yml new file mode 100644 index 000000000..9006a5daf --- /dev/null +++ b/.github/workflows/clean-release-notes.yml @@ -0,0 +1,37 @@ +name: Clean Release Notes + +on: + release: + types: [published] + +jobs: + clean-release-notes: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Remove ticket prefixes from release notes + uses: actions/github-script@v8 + with: + script: | + const release = context.payload.release; + + let body = release.body; + + if (!body) { + console.log("Release body empty, nothing to clean."); + return; + } + + // Remove ticket prefixes like "TPT-1234: " or "TPT-1234:" + body = body.replace(/TPT-\d+:\s*/g, ''); + + await github.rest.repos.updateRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + body: body + }); + + console.log("Release notes cleaned."); From c3a77de3f7afa4524efc96065bbc1f520cffb3a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:23:20 -0400 Subject: [PATCH 11/28] build(deps): bump actions/upload-artifact from 6 to 7 (#658) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-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 1298af319..50ee9205a 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -105,7 +105,7 @@ jobs: - name: Upload Test Report as Artifact if: always() - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: test-report-file if-no-files-found: ignore From fc2e33ce0f6b0f9d4fcd7360166fe73fc66d3b20 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:21:38 -0400 Subject: [PATCH 12/28] build(deps): bump slackapi/slack-github-action from 2.1.1 to 3 (#668) * build(deps): bump slackapi/slack-github-action from 2.1.1 to 3.0.1 Bumps [slackapi/slack-github-action](https://github.com/slackapi/slack-github-action) from 2.1.1 to 3.0.1. - [Release notes](https://github.com/slackapi/slack-github-action/releases) - [Commits](https://github.com/slackapi/slack-github-action/compare/v2.1.1...v3.0.1) --- updated-dependencies: - dependency-name: slackapi/slack-github-action dependency-version: 3.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Apply suggestions from code review Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zhiwei Liang <121905282+zliang-akamai@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 50ee9205a..8a02599cc 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -241,7 +241,7 @@ jobs: steps: - name: Notify Slack id: main_message - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} @@ -273,7 +273,7 @@ jobs: - name: Test summary thread if: success() - uses: slackapi/slack-github-action@v2.1.1 + uses: slackapi/slack-github-action@v3 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 644ea9ce4..c6697dc14 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.1 + uses: slackapi/slack-github-action@v3 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 4b01f094b..aa21d80e5 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.1 + uses: slackapi/slack-github-action@v3 with: method: chat.postMessage token: ${{ secrets.SLACK_BOT_TOKEN }} From 02cd38353720e5ef63bc8da7074ccae10aa6b6ec Mon Sep 17 00:00:00 2001 From: shkaruna Date: Wed, 8 Apr 2026 23:38:38 +0530 Subject: [PATCH 13/28] Remove content field from list alert channels response (#675) Co-authored-by: Ye Chen <127243817+yec-akamai@users.noreply.github.com> --- linode_api4/groups/monitor.py | 2 +- linode_api4/objects/monitor.py | 22 +--------------- test/fixtures/monitor_alert-channels.json | 31 +++++++++++++++++++++++ test/unit/objects/monitor_test.py | 25 +++++++++++++++++- 4 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 test/fixtures/monitor_alert-channels.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 66943ade5..4c36f2e9c 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -202,7 +202,7 @@ def alert_channels(self, *filters) -> PaginatedList: .. 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 + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-channels :param filters: Optional filter expressions to apply to the collection. See :doc:`Filtering Collections` for details. diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index ca8f83921..fea81c211 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -422,25 +422,6 @@ class AlertDefinition(DerivedBase): } -@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. - - @dataclass class EmailDetails(JSONObject): """ @@ -481,7 +462,7 @@ class AlertChannel(Base): 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 + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-notification-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. @@ -499,7 +480,6 @@ class AlertChannel(Base): "channel_type": Property(), "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), "created_by": Property(), diff --git a/test/fixtures/monitor_alert-channels.json b/test/fixtures/monitor_alert-channels.json new file mode 100644 index 000000000..753c53431 --- /dev/null +++ b/test/fixtures/monitor_alert-channels.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 123, + "label": "alert notification channel", + "type": "user", + "channel_type": "email", + "details": { + "email": { + "usernames": [ + "admin-user1", + "admin-user2" + ], + "recipient_type": "user" + } + }, + "alerts": { + "url": "/monitor/alert-channels/123/alerts", + "type": "alerts-definitions", + "alert_count": 0 + }, + "created": "2024-01-01T00:00:00", + "updated": "2024-01-01T00:00:00", + "created_by": "tester", + "updated_by": "tester" + } + ], + "page": 1, + "pages": 1, + "results": 1 +} \ No newline at end of file diff --git a/test/unit/objects/monitor_test.py b/test/unit/objects/monitor_test.py index 329a09063..5913b3b28 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, MonitorService +from linode_api4.objects import AlertChannel, MonitorDashboard, MonitorService class MonitorTest(ClientBaseCase): @@ -146,3 +146,26 @@ def test_create_token(self): service_type="linode", entity_ids=["compute-instance-1"] ) self.assertEqual(m.return_dct["token"], "abcdefhjigkfghh") + + def test_alert_channels(self): + channels = self.client.monitor.alert_channels() + + self.assertEqual(len(channels), 1) + self.assertIsInstance(channels[0], AlertChannel) + self.assertEqual(channels[0].id, 123) + self.assertEqual(channels[0].label, "alert notification channel") + self.assertEqual(channels[0].type, "user") + self.assertEqual(channels[0].channel_type, "email") + self.assertIsNotNone(channels[0].details) + self.assertIsNotNone(channels[0].details.email) + self.assertEqual( + channels[0].details.email.usernames, + ["admin-user1", "admin-user2"], + ) + self.assertEqual(channels[0].details.email.recipient_type, "user") + self.assertIsNotNone(channels[0].alerts) + self.assertEqual( + channels[0].alerts.url, + "/monitor/alert-channels/123/alerts", + ) + self.assertEqual(channels[0].alerts.alert_count, 0) From ecb5c3cc4a59b2fde64a1b436c76b24c5259acc1 Mon Sep 17 00:00:00 2001 From: shkaruna Date: Thu, 9 Apr 2026 19:04:29 +0530 Subject: [PATCH 14/28] feat: add ACLP list entities method (#674) * feat: add ACLP list entities method Add entities envelope in AlertDefinition. Add list entities GET API method. Add tests. * reorder scope and regions check * update alert definition object --- linode_api4/groups/monitor.py | 54 +++++++++++++- linode_api4/objects/monitor.py | 74 +++++++++++++------ test/fixtures/monitor_alert-definitions.json | 17 ++++- ...itor_services_dbaas_alert-definitions.json | 17 ++++- ...ervices_dbaas_alert-definitions_12345.json | 16 +++- ...baas_alert-definitions_12345_entities.json | 25 +++++++ .../models/monitor/test_monitor.py | 38 +++++++++- test/unit/groups/monitor_api_test.py | 64 ++++++++++++++++ 8 files changed, 274 insertions(+), 31 deletions(-) create mode 100644 test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json diff --git a/linode_api4/groups/monitor.py b/linode_api4/groups/monitor.py index 4c36f2e9c..0d7f19ce8 100644 --- a/linode_api4/groups/monitor.py +++ b/linode_api4/groups/monitor.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Optional, Union from linode_api4 import PaginatedList from linode_api4.errors import UnexpectedResponseError @@ -6,6 +6,8 @@ from linode_api4.objects import ( AlertChannel, AlertDefinition, + AlertDefinitionEntity, + AlertScope, MonitorDashboard, MonitorMetricsDefinition, MonitorService, @@ -221,6 +223,8 @@ def create_alert_definition( trigger_conditions: dict, entity_ids: Optional[list[str]] = None, description: Optional[str] = None, + scope: Optional[Union[AlertScope, str]] = None, + regions: Optional[list[str]] = None, ) -> AlertDefinition: """ Create a new alert definition for a given service type. @@ -252,6 +256,10 @@ def create_alert_definition( :type entity_ids: Optional[list[str]] :param description: (Optional) Longer description for the alert definition. :type description: Optional[str] + :param scope: (Optional) Alert scope (for example: `account`, `entity`, or `region`). Defaults to `entity`. + :type scope: Optional[Union[AlertScope, str]] + :param regions: (Optional) Regions to monitor. + :type regions: Optional[list[str]] :returns: The newly created :class:`AlertDefinition`. :rtype: AlertDefinition @@ -267,10 +275,15 @@ def create_alert_definition( "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 + if description is not None: + params["description"] = description + if scope is not None: + params["scope"] = scope + if regions is not None: + params["regions"] = regions # API will validate service_type and return an error if missing result = self.client.post( @@ -284,3 +297,38 @@ def create_alert_definition( ) return AlertDefinition(self.client, result["id"], service_type, result) + + def alert_definition_entities( + self, + service_type: str, + id: int, + *filters, + ) -> PaginatedList: + """ + List entities associated with a specific alert definition. + + This endpoint supports pagination fields (`page`, `page_size`) in the API. + + .. note:: This endpoint is in beta and requires using the v4beta base URL. + + API Documentation: TODO + + :param service_type: Service type for the alert definition (e.g. `dbaas`). + :type service_type: str + :param id: Alert definition identifier. + :type id: int + :param filters: Optional filter expressions to apply to the collection. + See :doc:`Filtering Collections`. + + :returns: A paginated list of entities associated with the alert definition. + :rtype: PaginatedList[AlertDefinitionEntity] + """ + + endpoint = ( + f"/monitor/services/{service_type}/alert-definitions/{id}/entities" + ) + return self.client._get_and_filter( + AlertDefinitionEntity, + *filters, + endpoint=endpoint, + ) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index fea81c211..1a83b59d6 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -7,11 +7,13 @@ __all__ = [ "AggregateFunction", - "Alert", "AlertChannel", "AlertDefinition", + "AlertDefinitionChannel", + "AlertDefinitionEntity", + "AlertEntities", + "AlertScope", "AlertType", - "Alerts", "MonitorDashboard", "MonitorMetricsDefinition", "MonitorService", @@ -341,15 +343,15 @@ class RuleCriteria(JSONObject): @dataclass -class Alert(JSONObject): +class AlertDefinitionChannel(JSONObject): """ - Represents an alert definition reference within an AlertChannel. + Represents the notification channel set up for use with an alert. 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 - Unique identifier for this notification channel. + - label: str - Human-readable name for the alert channel. + - type: str - Type of notification used with the channel. For a user alert definition, only `email` is supported. + - url: str - URL for the channel that ends in the channel's id. """ id: int = 0 @@ -358,18 +360,6 @@ class Alert(JSONObject): 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. @@ -387,6 +377,43 @@ class AlertType(StrEnum): user = "user" +class AlertScope(StrEnum): + """ + Scope values supported for alert definitions. + """ + + entity = "entity" + region = "region" + account = "account" + + +@dataclass +class AlertEntities(JSONObject): + """ + Represents entity metadata for an alert definition. + + For entity scoped alerts, `entities` envelope contains the URL to list entities, + a count, and a has_more_resources flag. + For region/account scoped alerts, the `entities` are returned as an empty object. + """ + + url: str = "" + count: int = 0 + has_more_resources: bool = False + + +@dataclass +class AlertDefinitionEntity(JSONObject): + """ + Represents an entity associated with an alert definition. + """ + + id: str = "" + label: str = "" + url: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + + class AlertDefinition(DerivedBase): """ Represents an alert definition for a monitor service. @@ -406,12 +433,12 @@ class AlertDefinition(DerivedBase): "severity": Property(mutable=True), "type": Property(mutable=True), "status": Property(mutable=True), - "has_more_resources": Property(mutable=True), + "has_more_resources": Property(), # Deprecated; use entities.has_more_resources. "rule_criteria": Property(mutable=True, json_object=RuleCriteria), "trigger_conditions": Property( mutable=True, json_object=TriggerConditions ), - "alert_channels": Property(mutable=True, json_object=Alerts), + "alert_channels": Property(json_object=AlertDefinitionChannel), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), "updated_by": Property(), @@ -419,6 +446,9 @@ class AlertDefinition(DerivedBase): "entity_ids": Property(mutable=True), "description": Property(mutable=True), "service_class": Property(alias_of="class"), + "scope": Property(AlertScope), + "regions": Property(mutable=True), + "entities": Property(json_object=AlertEntities), } diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json index 92b6e0e4c..e500354d1 100644 --- a/test/fixtures/monitor_alert-definitions.json +++ b/test/fixtures/monitor_alert-definitions.json @@ -7,13 +7,26 @@ "severity": 1, "type": "user", "description": "A test alert for dbaas service", + "scope": "entity", + "regions": [], "entity_ids": ["13217"], - "alert_channels": [], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/12345/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } + ], "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", diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json index 0c7067a8a..494b407d4 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -7,10 +7,24 @@ "severity": 1, "type": "user", "description": "A test alert for dbaas service", + "scope": "entity", + "regions": [], "entity_ids": [ "13217" ], - "alert_channels": [], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/12345/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } + ], "has_more_resources": false, "rule_criteria": { "rules": [ @@ -39,7 +53,6 @@ "trigger_occurrences": 3 }, "class": "alert", - "notification_groups": [], "status": "active", "created": "2024-01-01T00:00:00", "updated": "2024-01-01T00:00:00", diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json index 822e18b24..4b6a76272 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -5,10 +5,24 @@ "severity": 1, "type": "user", "description": "A test alert for dbaas service", + "scope": "entity", + "regions": [], "entity_ids": [ "13217" ], - "alert_channels": [], + "entities": { + "url": "/monitor/services/dbaas/alert-definitions/12345/entities", + "count": 1, + "has_more_resources": false + }, + "alert_channels": [ + { + "id": 10000, + "label": "Read-Write Channel", + "type": "email", + "url": "/monitor/alert-channels/10000" + } + ], "has_more_resources": false, "rule_criteria": { "rules": [ diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json new file mode 100644 index 000000000..16dad4b7c --- /dev/null +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345_entities.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "id": "1", + "label": "mydatabase-1", + "url": "/v4/databases/mysql/instances/1", + "type": "dbaas" + }, + { + "id": "2", + "label": "mydatabase-2", + "url": "/v4/databases/mysql/instances/2", + "type": "dbaas" + }, + { + "id": "3", + "label": "mydatabase-3", + "url": "/v4/databases/mysql/instances/3", + "type": "dbaas" + } + ], + "page": 1, + "pages": 1, + "results": 3 +} diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index 908ac1a44..ceb9fdc3a 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -7,9 +7,10 @@ import pytest -from linode_api4 import LinodeClient +from linode_api4 import LinodeClient, PaginatedList from linode_api4.objects import ( AlertDefinition, + AlertDefinitionEntity, ApiError, MonitorDashboard, MonitorMetricsDefinition, @@ -256,12 +257,14 @@ def wait_for_alert_ready(alert_id, service_type: str): assert created.id assert getattr(created, "label", None) == label + assert getattr(created, "entities", None) is not None created = wait_for_alert_ready(created.id, service_type) updated = client.load(AlertDefinition, created.id, service_type) updated.label = f"{label}-updated" updated.save() + assert getattr(updated, "entities", None) is not None updated = wait_for_alert_ready(updated.id, service_type) @@ -275,3 +278,36 @@ def wait_for_alert_ready(alert_id, service_type: str): AlertDefinition, created.id, service_type ) delete_alert.delete() + + +def test_alert_definition_entities(test_linode_client): + """Test listing entities associated with an alert definition. + + This test first retrieves alert definitions for a service type, then lists entities for the first alert definition. + It asserts that the returned entities have expected fields. + """ + client = test_linode_client + service_type = "dbaas" + + alert_definitions = client.monitor.alert_definitions( + service_type=service_type + ) + + if len(alert_definitions) == 0: + pytest.fail("No alert definitions available for dbaas service type") + + assert getattr(alert_definitions[0], "entities", None) is not None + + alert_def = alert_definitions[0] + entities = client.monitor.alert_definition_entities( + service_type, alert_def.id + ) + + assert isinstance(entities, PaginatedList) + if len(entities) > 0: + entity = entities[0] + assert isinstance(entity, AlertDefinitionEntity) + assert entity.id + assert entity.label + assert entity.url + assert entity._type == service_type diff --git a/test/unit/groups/monitor_api_test.py b/test/unit/groups/monitor_api_test.py index 9515895ae..fdc93060c 100644 --- a/test/unit/groups/monitor_api_test.py +++ b/test/unit/groups/monitor_api_test.py @@ -4,6 +4,8 @@ from linode_api4.objects import ( AggregateFunction, AlertDefinition, + AlertDefinitionChannel, + AlertDefinitionEntity, EntityMetricOptions, ) @@ -71,6 +73,20 @@ def test_alert_definition(self): # assert collection and element types assert isinstance(alert, PaginatedList) assert isinstance(alert[0], AlertDefinition) + assert alert[0].scope == "entity" + assert alert[0].regions == [] + assert alert[0].entities.url.endswith( + "/alert-definitions/12345/entities" + ) + assert alert[0].entities.count == 1 + assert alert[0].entities.has_more_resources is False + assert isinstance(alert[0].alert_channels, list) + assert len(alert[0].alert_channels) == 1 + assert isinstance( + alert[0].alert_channels[0], AlertDefinitionChannel + ) + assert alert[0].alert_channels[0].id == 10000 + assert alert[0].alert_channels[0]._type == "email" # fetch the raw JSON from the client and assert its fields raw = self.client.get(url) @@ -90,6 +106,11 @@ def test_create_alert_definition(self): "service_type": service_type, "severity": 1, "status": "active", + "entities": { + "url": f"/monitor/services/dbaas/alert-definitions/67890/entities", + "count": 1, + "has_more_resources": False, + }, } with self.mock_post(result) as mock_post: @@ -100,6 +121,8 @@ def test_create_alert_definition(self): channel_ids=[1, 2], rule_criteria={"rules": []}, trigger_conditions={"criteria_condition": "ALL"}, + scope="entity", + regions=[], entity_ids=["13217"], description="created via test", ) @@ -109,10 +132,51 @@ def test_create_alert_definition(self): assert mock_post.call_data["label"] == "Created Alert" assert mock_post.call_data["severity"] == 1 assert "channel_ids" in mock_post.call_data + assert mock_post.call_data["scope"] == "entity" + assert mock_post.call_data["regions"] == [] assert isinstance(alert, AlertDefinition) assert alert.id == 67890 + assert alert.entities.url.endswith( + "/alert-definitions/67890/entities" + ) + assert alert.entities.count == 1 + assert alert.entities.has_more_resources is False # fetch the same response from the client and assert resp = self.client.post(url, data={}) assert resp["label"] == "Created Alert" + + def test_alert_definition_entities(self): + service_type = "dbaas" + id = 12345 + url = ( + f"/monitor/services/{service_type}/alert-definitions/{id}/entities" + ) + + with self.mock_get(url) as mock_get: + entities = self.client.monitor.alert_definition_entities( + service_type, id + ) + + assert mock_get.call_url == url + assert isinstance(entities, PaginatedList) + assert len(entities) == 3 + + assert isinstance(entities[0], AlertDefinitionEntity) + assert entities[0].id == "1" + assert entities[0].label == "mydatabase-1" + assert entities[0].url == "/v4/databases/mysql/instances/1" + assert entities[0]._type == "dbaas" + + assert isinstance(entities[1], AlertDefinitionEntity) + assert entities[1].id == "2" + assert entities[1].label == "mydatabase-2" + assert entities[1].url == "/v4/databases/mysql/instances/2" + assert entities[1]._type == "dbaas" + + assert isinstance(entities[2], AlertDefinitionEntity) + assert entities[2].id == "3" + assert entities[2].label == "mydatabase-3" + assert entities[2].url == "/v4/databases/mysql/instances/3" + assert entities[2]._type == "dbaas" From 206a792289272ae8027182d627989dcff56df019 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:25:28 -0400 Subject: [PATCH 15/28] Cleanup block storage encryption LA notice (#655) --- linode_api4/groups/linode.py | 1 - linode_api4/groups/volume.py | 1 - linode_api4/objects/linode.py | 2 -- 3 files changed, 4 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index 2bd51fa97..a433a0dcf 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -322,7 +322,6 @@ 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/groups/volume.py b/linode_api4/groups/volume.py index 39d0aeaaa..847c3030c 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -48,7 +48,6 @@ def create(self, label, region=None, linode=None, size=20, **kwargs): :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 9b76668e3..b8b0fc9dc 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1448,7 +1448,6 @@ def disk_create( 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. @@ -1642,7 +1641,6 @@ 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 c39c86850e132997ab3012a8475c34274cfb0f8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 13:54:14 -0400 Subject: [PATCH 16/28] build(deps): bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#681) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.13.0 to 1.14.0. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e...cef221092ed1bacb1cc03d23a2d87d1d172e277b) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.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 a791be4c9..86d854455 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@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # pin@release/v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # pin@release/v1.14.0 From 9535d635d19325db2511b2388b528c818e72b1b9 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:56:39 -0400 Subject: [PATCH 17/28] Use e2e firewall in all linode interface tests (#685) --- test/integration/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/integration/conftest.py b/test/integration/conftest.py index a5c832f4f..0058dfcec 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -16,7 +16,6 @@ from requests.exceptions import ConnectionError, RequestException from linode_api4 import ( - ExplicitNullValue, InterfaceGeneration, LinodeInterfaceDefaultRouteOptions, LinodeInterfaceOptions, @@ -645,7 +644,7 @@ def linode_with_linode_interfaces( public=LinodeInterfacePublicOptions(), ), LinodeInterfaceOptions( - firewall_id=ExplicitNullValue, + firewall_id=e2e_test_firewall.id, vpc=LinodeInterfaceVPCOptions( subnet_id=subnet.id, ), From 03c5f8c509786b0adab68af07ae05720011f413d Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:40:06 -0400 Subject: [PATCH 18/28] Remove outdated note about Linode interfaces availability (#683) --- linode_api4/objects/linode.py | 6 -- linode_api4/objects/linode_interfaces.py | 74 ------------------------ linode_api4/objects/networking.py | 2 - 3 files changed, 82 deletions(-) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index b8b0fc9dc..bf2f12717 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -2055,8 +2055,6 @@ def interface_create( 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:: @@ -2132,8 +2130,6 @@ 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 """ @@ -2202,8 +2198,6 @@ def upgrade_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 diff --git a/linode_api4/objects/linode_interfaces.py b/linode_api4/objects/linode_interfaces.py index 0598d1f3c..69cebca23 100644 --- a/linode_api4/objects/linode_interfaces.py +++ b/linode_api4/objects/linode_interfaces.py @@ -11,8 +11,6 @@ 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 @@ -23,8 +21,6 @@ class LinodeInterfacesSettingsDefaultRouteOptions(JSONObject): 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 @@ -40,8 +36,6 @@ 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" @@ -60,8 +54,6 @@ class LinodeInterfacesSettings(Base): 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 @@ -72,8 +64,6 @@ class LinodeInterfaceDefaultRouteOptions(JSONObject): 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 @@ -85,8 +75,6 @@ class LinodeInterfaceVPCIPv4AddressOptions(JSONObject): 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 = "" @@ -96,8 +84,6 @@ class LinodeInterfaceVPCIPv4RangeOptions(JSONObject): 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 @@ -108,8 +94,6 @@ class LinodeInterfaceVPCIPv4Options(JSONObject): 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 @@ -119,8 +103,6 @@ class LinodeInterfaceVPCIPv6SLAACOptions(JSONObject): 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 @@ -130,8 +112,6 @@ class LinodeInterfaceVPCIPv6RangeOptions(JSONObject): 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 @@ -143,8 +123,6 @@ class LinodeInterfaceVPCIPv6Options(JSONObject): 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 @@ -156,8 +134,6 @@ class LinodeInterfaceVPCOptions(JSONObject): 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 = "" @@ -168,8 +144,6 @@ class LinodeInterfacePublicIPv4AddressOptions(JSONObject): 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 @@ -179,8 +153,6 @@ class LinodeInterfacePublicIPv4Options(JSONObject): 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 = "" @@ -190,8 +162,6 @@ class LinodeInterfacePublicIPv6RangeOptions(JSONObject): 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 @@ -201,8 +171,6 @@ class LinodeInterfacePublicIPv6Options(JSONObject): 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 @@ -213,8 +181,6 @@ class LinodeInterfacePublicOptions(JSONObject): 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 = "" @@ -225,8 +191,6 @@ class LinodeInterfaceVLANOptions(JSONObject): class LinodeInterfaceOptions(JSONObject): """ Options accepted when creating or updating a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ always_include = { @@ -249,8 +213,6 @@ class LinodeInterfaceOptions(JSONObject): 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 @@ -263,8 +225,6 @@ class LinodeInterfaceDefaultRoute(JSONObject): 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 @@ -278,8 +238,6 @@ class LinodeInterfaceVPCIPv4Address(JSONObject): 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 @@ -291,8 +249,6 @@ class LinodeInterfaceVPCIPv4Range(JSONObject): 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 @@ -305,8 +261,6 @@ class LinodeInterfaceVPCIPv4(JSONObject): 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 = "" @@ -317,8 +271,6 @@ class LinodeInterfaceVPCIPv6SLAAC(JSONObject): 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 = "" @@ -328,8 +280,6 @@ class LinodeInterfaceVPCIPv6Range(JSONObject): 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 @@ -343,8 +293,6 @@ class LinodeInterfaceVPCIPv6(JSONObject): 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 @@ -360,8 +308,6 @@ class LinodeInterfaceVPC(JSONObject): 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 @@ -374,8 +320,6 @@ class LinodeInterfacePublicIPv4Address(JSONObject): 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 = "" @@ -386,8 +330,6 @@ class LinodeInterfacePublicIPv4Shared(JSONObject): 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 @@ -402,8 +344,6 @@ class LinodeInterfacePublicIPv4(JSONObject): 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 = "" @@ -414,8 +354,6 @@ class LinodeInterfacePublicIPv6SLAAC(JSONObject): 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 = "" @@ -426,8 +364,6 @@ class LinodeInterfacePublicIPv6Shared(JSONObject): 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 @@ -440,8 +376,6 @@ class LinodeInterfacePublicIPv6Range(JSONObject): class LinodeInterfacePublicIPv6(JSONObject): """ The IPv6 configuration of a Linode Interface. - - NOTE: Linode interfaces may not currently be available to all users. """ put_class = LinodeInterfacePublicIPv6Options @@ -455,8 +389,6 @@ class LinodeInterfacePublicIPv6(JSONObject): 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 @@ -469,8 +401,6 @@ class LinodeInterfacePublic(JSONObject): 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 @@ -483,8 +413,6 @@ 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:: @@ -528,8 +456,6 @@ 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. diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index ed975ab71..44e4599b2 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -108,8 +108,6 @@ def interface(self) -> Optional["LinodeInterface"]: 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 1a32329a4e05c13fe6aa9697c84c39738d8f2fdb Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Mon, 4 May 2026 14:36:49 -0400 Subject: [PATCH 19/28] TPT-4224: Add global quotas and quota usage support for OBJ services (#661) * Add global quotas and quota usage support for OBJ services * make format * Fix test --- linode_api4/groups/object_storage.py | 16 ++++ linode_api4/objects/object_storage.py | 40 ++++++++++ .../object-storage_global-quotas.json | 25 +++++++ ...al-quotas_obj-access-keys-per-account.json | 9 +++ ...tas_obj-access-keys-per-account_usage.json | 4 + test/fixtures/object-storage_quotas.json | 8 +- ...t-storage_quotas_obj-objects-us-ord-1.json | 4 +- .../models/object_storage/test_obj_quotas.py | 73 ++++++++++++++++++- test/unit/objects/object_storage_test.py | 59 +++++++++++++++ 9 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/object-storage_global-quotas.json create mode 100644 test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json create mode 100644 test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json diff --git a/linode_api4/groups/object_storage.py b/linode_api4/groups/object_storage.py index 5ffab3ffc..d36690111 100644 --- a/linode_api4/groups/object_storage.py +++ b/linode_api4/groups/object_storage.py @@ -19,6 +19,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageGlobalQuota, ObjectStorageKeyPermission, ObjectStorageKeys, ObjectStorageQuota, @@ -533,3 +534,18 @@ def quotas(self, *filters): :rtype: PaginatedList of ObjectStorageQuota """ return self.client._get_and_filter(ObjectStorageQuota, *filters) + + def global_quotas(self, *filters): + """ + Lists the active account-level Object Storage quotas applied to your account. + + 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 account-level Object Storage Quotas that matched the query. + :rtype: PaginatedList of ObjectStorageGlobalQuota + """ + return self.client._get_and_filter(ObjectStorageGlobalQuota, *filters) diff --git a/linode_api4/objects/object_storage.py b/linode_api4/objects/object_storage.py index a2e61405f..fdb91e180 100644 --- a/linode_api4/objects/object_storage.py +++ b/linode_api4/objects/object_storage.py @@ -596,6 +596,8 @@ class ObjectStorageQuota(Base): "description": Property(), "quota_limit": Property(), "resource_metric": Property(), + "quota_type": Property(), + "has_usage": Property(), } def usage(self): @@ -614,3 +616,41 @@ def usage(self): ) return ObjectStorageQuotaUsage.from_json(result) + + +class ObjectStorageGlobalQuota(Base): + """ + An account-level Object Storage quota. + + API documentation: TBD + """ + + api_endpoint = "/object-storage/global-quotas/{quota_id}" + id_attribute = "quota_id" + + properties = { + "quota_id": Property(identifier=True), + "quota_type": Property(), + "quota_name": Property(), + "description": Property(), + "resource_metric": Property(), + "quota_limit": Property(), + "has_usage": Property(), + } + + def usage(self): + """ + Gets usage data for a specific account-level Object Storage quota. + + API documentation: https://techdocs.akamai.com/linode-api/reference/get-object-storage-global-quota-usage + + :returns: The Object Storage Global 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_global-quotas.json b/test/fixtures/object-storage_global-quotas.json new file mode 100644 index 000000000..c9cc73b8c --- /dev/null +++ b/test/fixtures/object-storage_global-quotas.json @@ -0,0 +1,25 @@ +{ + "data": [ + { + "quota_id": "obj-access-keys-per-account", + "quota_type": "obj-access-keys", + "quota_name": "Object Storage Access Keys per Account", + "description": "Maximum number of access keys this customer is allowed to have on their account.", + "resource_metric": "access_key", + "quota_limit": 100, + "has_usage": true + }, + { + "quota_id": "obj-total-capacity-per-account", + "quota_type": "obj-total-capacity", + "quota_name": "Object Storage Total Capacity per Account", + "description": "Maximum total storage capacity in bytes this customer is allowed on their account.", + "resource_metric": "byte", + "quota_limit": 1099511627776, + "has_usage": true + } + ], + "page": 1, + "pages": 1, + "results": 2 +} diff --git a/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json new file mode 100644 index 000000000..b3f167550 --- /dev/null +++ b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account.json @@ -0,0 +1,9 @@ +{ + "quota_id": "obj-access-keys-per-account", + "quota_type": "obj-access-keys", + "quota_name": "Object Storage Access Keys per Account", + "description": "Maximum number of access keys this customer is allowed to have on their account.", + "resource_metric": "access_key", + "quota_limit": 100, + "has_usage": true +} diff --git a/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json new file mode 100644 index 000000000..ae3be8a3c --- /dev/null +++ b/test/fixtures/object-storage_global-quotas_obj-access-keys-per-account_usage.json @@ -0,0 +1,4 @@ +{ + "quota_limit": 100, + "usage": 25 +} diff --git a/test/fixtures/object-storage_quotas.json b/test/fixtures/object-storage_quotas.json index e831d7303..e6b11554a 100644 --- a/test/fixtures/object-storage_quotas.json +++ b/test/fixtures/object-storage_quotas.json @@ -7,7 +7,9 @@ "endpoint_type": "E1", "s3_endpoint": "us-iad-1.linodeobjects.com", "quota_limit": 50, - "resource_metric": "object" + "resource_metric": "object", + "quota_type": "obj-objects", + "has_usage": true }, { "quota_id": "obj-bucket-us-ord-1", @@ -16,7 +18,9 @@ "endpoint_type": "E1", "s3_endpoint": "us-iad-1.linodeobjects.com", "quota_limit": 50, - "resource_metric": "bucket" + "resource_metric": "bucket", + "quota_type": "obj-bucket", + "has_usage": true } ], "page": 1, 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 index e01d743c3..fe216e776 100644 --- a/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json +++ b/test/fixtures/object-storage_quotas_obj-objects-us-ord-1.json @@ -5,5 +5,7 @@ "endpoint_type": "E1", "s3_endpoint": "us-iad-1.linodeobjects.com", "quota_limit": 50, - "resource_metric": "object" + "resource_metric": "object", + "quota_type": "obj-objects", + "has_usage": true } \ No newline at end of file diff --git a/test/integration/models/object_storage/test_obj_quotas.py b/test/integration/models/object_storage/test_obj_quotas.py index 10a546bc7..d9be84c3d 100644 --- a/test/integration/models/object_storage/test_obj_quotas.py +++ b/test/integration/models/object_storage/test_obj_quotas.py @@ -1,6 +1,8 @@ import pytest +from linode_api4.errors import ApiError from linode_api4.objects.object_storage import ( + ObjectStorageGlobalQuota, ObjectStorageQuota, ObjectStorageQuotaUsage, ) @@ -25,6 +27,8 @@ def test_list_and_get_obj_storage_quotas(test_linode_client): 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 + assert found_quota.quota_type == get_quota.quota_type + assert found_quota.has_usage == get_quota.has_usage def test_get_obj_storage_quota_usage(test_linode_client): @@ -33,7 +37,21 @@ def test_get_obj_storage_quota_usage(test_linode_client): if len(quotas) < 1: pytest.skip("No available quota for testing. Skipping now...") - quota_id = quotas[0].quota_id + quota_with_usage = next( + (quota for quota in quotas if quota.has_usage), None + ) + + if quota_with_usage is None: + quota_id = quotas[0].quota_id + quota = test_linode_client.load(ObjectStorageQuota, quota_id) + + # quota without usage should return an API error on usage retrieval + with pytest.raises(ApiError): + quota.usage() + + return + + quota_id = quota_with_usage.quota_id quota = test_linode_client.load(ObjectStorageQuota, quota_id) quota_usage = quota.usage() @@ -43,3 +61,56 @@ def test_get_obj_storage_quota_usage(test_linode_client): if quota_usage.usage is not None: assert quota_usage.usage >= 0 + + +def test_list_and_get_obj_storage_global_quotas(test_linode_client): + quotas = test_linode_client.object_storage.global_quotas() + + if len(quotas) < 1: + pytest.skip("No available global quota for testing. Skipping now...") + + found_quota = quotas[0] + + get_quota = test_linode_client.load( + ObjectStorageGlobalQuota, found_quota.quota_id + ) + + assert found_quota.quota_id == get_quota.quota_id + assert found_quota.quota_type == get_quota.quota_type + assert found_quota.quota_name == get_quota.quota_name + assert found_quota.description == get_quota.description + assert found_quota.resource_metric == get_quota.resource_metric + assert found_quota.quota_limit == get_quota.quota_limit + assert found_quota.has_usage == get_quota.has_usage + + +def test_get_obj_storage_global_quota_usage(test_linode_client): + quotas = test_linode_client.object_storage.global_quotas() + + if len(quotas) < 1: + pytest.skip("No available global quota for testing. Skipping now...") + + quota_with_usage = next( + (quota for quota in quotas if quota.has_usage), None + ) + + if quota_with_usage is None: + quota_id = quotas[0].quota_id + quota = test_linode_client.load(ObjectStorageGlobalQuota, quota_id) + + # quota without usage should return an API error on usage retrieval + with pytest.raises(ApiError): + quota.usage() + + return + + quota_id = quota_with_usage.quota_id + quota = test_linode_client.load(ObjectStorageGlobalQuota, quota_id) + + quota_usage = quota.usage() + + assert isinstance(quota_usage, ObjectStorageQuotaUsage) + assert quota_usage.quota_limit >= 0 + + if quota_usage.usage is not None: + assert quota_usage.usage >= 0 diff --git a/test/unit/objects/object_storage_test.py b/test/unit/objects/object_storage_test.py index b7ff7e49c..e0deb4211 100644 --- a/test/unit/objects/object_storage_test.py +++ b/test/unit/objects/object_storage_test.py @@ -6,6 +6,7 @@ ObjectStorageACL, ObjectStorageBucket, ObjectStorageCluster, + ObjectStorageGlobalQuota, ObjectStorageQuota, ) @@ -306,6 +307,8 @@ def test_quota_get_and_list(self): self.assertEqual(quota.s3_endpoint, "us-iad-1.linodeobjects.com") self.assertEqual(quota.quota_limit, 50) self.assertEqual(quota.resource_metric, "object") + self.assertEqual(quota.quota_type, "obj-objects") + self.assertTrue(quota.has_usage) quota_usage_url = "/object-storage/quotas/obj-objects-us-ord-1/usage" with self.mock_get(quota_usage_url) as m: @@ -335,3 +338,59 @@ def test_quota_get_and_list(self): ) self.assertEqual(quotas[0].quota_limit, 50) self.assertEqual(quotas[0].resource_metric, "object") + self.assertEqual(quotas[0].quota_type, "obj-objects") + self.assertTrue(quotas[0].has_usage) + + def test_global_quota_get_and_list(self): + """ + Test that you can get and list account-level Object Storage global quotas and usage. + """ + quota = ObjectStorageGlobalQuota( + self.client, + "obj-access-keys-per-account", + ) + + self.assertIsNotNone(quota) + self.assertEqual(quota.quota_id, "obj-access-keys-per-account") + self.assertEqual(quota.quota_type, "obj-access-keys") + self.assertEqual( + quota.quota_name, + "Object Storage Access Keys per Account", + ) + self.assertEqual( + quota.description, + "Maximum number of access keys this customer is allowed to have on their account.", + ) + self.assertEqual(quota.resource_metric, "access_key") + self.assertEqual(quota.quota_limit, 100) + self.assertTrue(quota.has_usage) + + usage_url = ( + "/object-storage/global-quotas/obj-access-keys-per-account/usage" + ) + with self.mock_get(usage_url) as m: + usage = quota.usage() + self.assertIsNotNone(usage) + self.assertEqual(m.call_url, usage_url) + self.assertEqual(usage.quota_limit, 100) + self.assertEqual(usage.usage, 25) + + list_url = "/object-storage/global-quotas" + with self.mock_get(list_url) as m: + quotas = self.client.object_storage.global_quotas() + self.assertIsNotNone(quotas) + self.assertEqual(m.call_url, list_url) + self.assertEqual(len(quotas), 2) + self.assertEqual(quotas[0].quota_id, "obj-access-keys-per-account") + self.assertEqual(quotas[0].quota_type, "obj-access-keys") + self.assertEqual( + quotas[0].quota_name, + "Object Storage Access Keys per Account", + ) + self.assertEqual( + quotas[0].description, + "Maximum number of access keys this customer is allowed to have on their account.", + ) + self.assertEqual(quotas[0].resource_metric, "access_key") + self.assertEqual(quotas[0].quota_limit, 100) + self.assertTrue(quotas[0].has_usage) From 4fec1e5e0f8f4857cf2abb82db93718f5823c737 Mon Sep 17 00:00:00 2001 From: shkaruna Date: Wed, 6 May 2026 00:48:51 +0530 Subject: [PATCH 20/28] ACLP: Cleanup entity related response fields from alert definition (#690) * feat: add ACLP list entities method Add entities envelope in AlertDefinition. Add list entities GET API method. Add tests. * reorder scope and regions check * update alert definition object * Cleanup entity related fields from alert definition Remove entity_ids and has_more_resources response fields * ref: add channel_ids property in alert definition --- linode_api4/objects/monitor.py | 2 +- test/fixtures/monitor_alert-definitions.json | 2 -- test/fixtures/monitor_services_dbaas_alert-definitions.json | 4 ---- .../monitor_services_dbaas_alert-definitions_12345.json | 4 ---- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 1a83b59d6..7e0f4ae4d 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -433,7 +433,6 @@ class AlertDefinition(DerivedBase): "severity": Property(mutable=True), "type": Property(mutable=True), "status": Property(mutable=True), - "has_more_resources": Property(), # Deprecated; use entities.has_more_resources. "rule_criteria": Property(mutable=True, json_object=RuleCriteria), "trigger_conditions": Property( mutable=True, json_object=TriggerConditions @@ -449,6 +448,7 @@ class AlertDefinition(DerivedBase): "scope": Property(AlertScope), "regions": Property(mutable=True), "entities": Property(json_object=AlertEntities), + "channel_ids": Property(mutable=True), } diff --git a/test/fixtures/monitor_alert-definitions.json b/test/fixtures/monitor_alert-definitions.json index e500354d1..2e040605f 100644 --- a/test/fixtures/monitor_alert-definitions.json +++ b/test/fixtures/monitor_alert-definitions.json @@ -9,7 +9,6 @@ "description": "A test alert for dbaas service", "scope": "entity", "regions": [], - "entity_ids": ["13217"], "entities": { "url": "/monitor/services/dbaas/alert-definitions/12345/entities", "count": 1, @@ -23,7 +22,6 @@ "url": "/monitor/alert-channels/10000" } ], - "has_more_resources": false, "rule_criteria": null, "trigger_conditions": null, "class": "alert", diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions.json b/test/fixtures/monitor_services_dbaas_alert-definitions.json index 494b407d4..67ea9d2ab 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions.json @@ -9,9 +9,6 @@ "description": "A test alert for dbaas service", "scope": "entity", "regions": [], - "entity_ids": [ - "13217" - ], "entities": { "url": "/monitor/services/dbaas/alert-definitions/12345/entities", "count": 1, @@ -25,7 +22,6 @@ "url": "/monitor/alert-channels/10000" } ], - "has_more_resources": false, "rule_criteria": { "rules": [ { diff --git a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json index 4b6a76272..4d70f66b1 100644 --- a/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json +++ b/test/fixtures/monitor_services_dbaas_alert-definitions_12345.json @@ -7,9 +7,6 @@ "description": "A test alert for dbaas service", "scope": "entity", "regions": [], - "entity_ids": [ - "13217" - ], "entities": { "url": "/monitor/services/dbaas/alert-definitions/12345/entities", "count": 1, @@ -23,7 +20,6 @@ "url": "/monitor/alert-channels/10000" } ], - "has_more_resources": false, "rule_criteria": { "rules": [ { From b690c4f2717f7f764c96fc50140bb1c0c89357b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 12:52:36 -0400 Subject: [PATCH 21/28] build(deps): bump actions/github-script from 8 to 9 (#682) Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' 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/clean-release-notes.yml | 2 +- .github/workflows/e2e-test-pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/clean-release-notes.yml b/.github/workflows/clean-release-notes.yml index 9006a5daf..9b7305275 100644 --- a/.github/workflows/clean-release-notes.yml +++ b/.github/workflows/clean-release-notes.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Remove ticket prefixes from release notes - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | const release = context.payload.release; diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index f765b0a0d..de95ac92b 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -123,7 +123,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@v8 + - uses: actions/github-script@v9 id: update-check-run if: ${{ inputs.pull_request_number != '' && fromJson(steps.commit-hash.outputs.data).repository.pullRequest.headRef.target.oid == inputs.sha }} env: From 7bf9b4ddcd469e203f815388310edaa9af18f44f Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Thu, 7 May 2026 10:16:19 -0400 Subject: [PATCH 22/28] docs: update API documentation links in Lock classes (#679) --- linode_api4/groups/lock.py | 4 ++-- linode_api4/objects/lock.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/linode_api4/groups/lock.py b/linode_api4/groups/lock.py index 42cc58d80..2f19e2c1d 100644 --- a/linode_api4/groups/lock.py +++ b/linode_api4/groups/lock.py @@ -24,7 +24,7 @@ def __call__(self, *filters): locks = client.locks() - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-locks :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -44,7 +44,7 @@ def create( """ Creates a new Resource Lock for the specified entity. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-resource-lock :param entity_type: The type of entity to lock (e.g., "linode"). :type entity_type: str diff --git a/linode_api4/objects/lock.py b/linode_api4/objects/lock.py index 9cee64517..3a1cd32d2 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: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-lock """ cannot_delete = "cannot_delete" @@ -22,7 +22,7 @@ class LockEntity(JSONObject): """ Represents the entity that is locked. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-lock """ id: int = 0 @@ -35,7 +35,7 @@ class Lock(Base): """ A resource lock that prevents deletion or modification of a resource. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-resource-lock """ api_endpoint = "/locks/{id}" From bf13f2e0d9a0b637f894b61ea253cb88db1c4ac0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 09:13:57 +0200 Subject: [PATCH 23/28] build(deps): bump actions/dependency-review-action from 4 to 5 (#695) Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5. - [Release notes](https://github.com/actions/dependency-review-action/releases) - [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/dependency-review-action 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/dependency-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index ffba32062..e5c6ebdfe 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -13,6 +13,6 @@ jobs: - name: 'Checkout repository' uses: actions/checkout@v6 - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 with: comment-summary-in-pr: on-failure From 89851092e4fa858a67ea7b73c4b53ccca173113b Mon Sep 17 00:00:00 2001 From: Maciej Wilk Date: Tue, 12 May 2026 15:06:21 +0200 Subject: [PATCH 24/28] Change assignments type from dict to list (#687) --- linode_api4/groups/networking.py | 4 ++-- test/unit/linode_client_test.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index b16d12d9a..502b7f68e 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -452,10 +452,10 @@ def ip_addresses_assign(self, assignments, region): :param assignments: Any number of assignments to make. See :any:`IPAddress.to` for details on how to construct assignments. - :type assignments: dct + :type assignments: list """ - for a in assignments["assignments"]: + for a in assignments: if not "address" in a or not "linode_id" in a: raise ValueError("Invalid assignment: {}".format(a)) diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index e82f3562d..4d0db5753 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -1407,13 +1407,13 @@ def test_ip_addresses_assign(self): with self.mock_post({}) as m: self.client.networking.ip_addresses_assign( - {"assignments": [{"address": "192.0.2.1", "linode_id": 123}]}, + [{"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}]}, + [{"address": "192.0.2.1", "linode_id": 123}], ) self.assertEqual(m.call_data["region"], "us-east") From 68772a8a041586d1cf29e4e2434a56c7500320fb Mon Sep 17 00:00:00 2001 From: Ye Chen <127243817+yec-akamai@users.noreply.github.com> Date: Tue, 12 May 2026 09:48:52 -0400 Subject: [PATCH 25/28] Update Images in integration tests (#694) * update images * fix lint --- linode_api4/objects/database.py | 7 +------ test/integration/linode_client/test_linode_client.py | 7 ++----- test/integration/models/linode/test_linode.py | 6 +++--- test/integration/models/sharegroups/test_sharegroups.py | 6 ++---- 4 files changed, 8 insertions(+), 18 deletions(-) diff --git a/linode_api4/objects/database.py b/linode_api4/objects/database.py index b3c6f8c35..a3a918bbe 100644 --- a/linode_api4/objects/database.py +++ b/linode_api4/objects/database.py @@ -1,12 +1,7 @@ from dataclasses import dataclass, field from typing import Optional -from linode_api4.objects import ( - Base, - JSONObject, - MappedObject, - Property, -) +from linode_api4.objects import Base, JSONObject, MappedObject, Property class DatabaseType(Base): diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 4060064d3..b855c4bf2 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -116,12 +116,9 @@ def test_fails_to_create_image_with_non_existing_disk_id( disk_id = 111111 try: - image_page = client.image_create( - disk=disk_id, label=label, description=description - ) + 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 + assert 400 <= e.status < 500 def test_fails_to_delete_predefined_images(setup_client_and_linode): diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 512b6c513..bc642ab21 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -141,7 +141,7 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/alpine3.19", + image="linode/ubuntu24.04", label=label + "_long_tests", firewall=e2e_test_firewall, ) @@ -174,7 +174,7 @@ def linode_with_block_storage_encryption(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/alpine3.19", + image="linode/ubuntu24.04", label=label + "block-storage-encryption", firewall=e2e_test_firewall, ) @@ -215,7 +215,7 @@ def linode_with_disk_encryption(test_linode_client, request): linode_instance, password = client.linode.instance_create( "g6-nanode-1", target_region, - image="linode/ubuntu24.10", + image="linode/ubuntu24.04", label=label, booted=False, disk_encryption=disk_encryption, diff --git a/test/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py index 9c66bad90..776454c9a 100644 --- a/test/integration/models/sharegroups/test_sharegroups.py +++ b/test/integration/models/sharegroups/test_sharegroups.py @@ -1,8 +1,6 @@ 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 import pytest @@ -44,7 +42,7 @@ def sample_linode(test_linode_client, e2e_test_firewall): linode_instance, password = client.linode.instance_create( "g6-nanode-1", region, - image="linode/alpine3.19", + image="linode/ubuntu24.04", label=label + "_modlinode", ) yield linode_instance From 368aed4792c4dd1661a6b470f47a1429acb2e881 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang <121905282+zliang-akamai@users.noreply.github.com> Date: Tue, 12 May 2026 10:39:04 -0400 Subject: [PATCH 26/28] Add support for firewall rules version and fingerprint (#656) --- test/fixtures/networking_firewalls.json | 8 +++++--- test/fixtures/networking_firewalls_123.json | 8 +++++--- test/fixtures/networking_firewalls_123_rules.json | 4 +++- test/integration/models/firewall/test_firewall.py | 8 ++++++++ test/unit/objects/firewall_test.py | 2 ++ 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/test/fixtures/networking_firewalls.json b/test/fixtures/networking_firewalls.json index 0bd9660f1..1c5bfa3f8 100644 --- a/test/fixtures/networking_firewalls.json +++ b/test/fixtures/networking_firewalls.json @@ -3,14 +3,16 @@ { "id":123, "label":"test-firewall-1", - "created":"2018-01-01T00:01:01", - "updated":"2018-01-01T00:01:01", + "created":"2018-01-01T00:01:03", + "updated":"2018-01-01T00:01:03", "status":"enabled", "rules":{ "outbound":[], "outbound_policy":"DROP", "inbound":[], - "inbound_policy":"DROP" + "inbound_policy":"DROP", + "version":2, + "fingerprint":"4ef67a29" }, "tags":[] } diff --git a/test/fixtures/networking_firewalls_123.json b/test/fixtures/networking_firewalls_123.json index c34a3991e..30c90726b 100644 --- a/test/fixtures/networking_firewalls_123.json +++ b/test/fixtures/networking_firewalls_123.json @@ -1,14 +1,16 @@ { "id":123, "label":"test-firewall-1", - "created":"2018-01-01T00:01:01", - "updated":"2018-01-01T00:01:01", + "created":"2018-01-01T00:01:02", + "updated":"2018-01-01T00:01:02", "status":"enabled", "rules":{ "outbound":[], "outbound_policy":"DROP", "inbound":[], - "inbound_policy":"DROP" + "inbound_policy":"DROP", + "version":2, + "fingerprint":"4ef67a29" }, "tags":[] } \ No newline at end of file diff --git a/test/fixtures/networking_firewalls_123_rules.json b/test/fixtures/networking_firewalls_123_rules.json index 43c8af4dc..d3f0716c4 100644 --- a/test/fixtures/networking_firewalls_123_rules.json +++ b/test/fixtures/networking_firewalls_123_rules.json @@ -2,5 +2,7 @@ "inbound": [], "inbound_policy": "DROP", "outbound": [], - "outbound_policy": "DROP" + "outbound_policy": "DROP", + "version": 2, + "fingerprint": "4ef67a29" } \ No newline at end of file diff --git a/test/integration/models/firewall/test_firewall.py b/test/integration/models/firewall/test_firewall.py index 16805f3b8..6bc5a9988 100644 --- a/test/integration/models/firewall/test_firewall.py +++ b/test/integration/models/firewall/test_firewall.py @@ -29,6 +29,10 @@ def test_get_firewall_rules(test_linode_client, test_firewall): assert rules.inbound_policy in ["ACCEPT", "DROP"] assert rules.outbound_policy in ["ACCEPT", "DROP"] + assert isinstance(rules.version, int) + assert rules.version > 0 + assert isinstance(rules.fingerprint, str) + assert len(rules.fingerprint) > 0 @pytest.mark.smoke @@ -61,6 +65,10 @@ def test_update_firewall_rules(test_linode_client, test_firewall): assert firewall.rules.inbound_policy == "ACCEPT" assert firewall.rules.outbound_policy == "DROP" + assert isinstance(firewall.rules.version, int) + assert firewall.rules.version > 0 + assert isinstance(firewall.rules.fingerprint, str) + assert len(firewall.rules.fingerprint) > 0 def test_get_devices(test_linode_client, linode_fw, test_firewall): diff --git a/test/unit/objects/firewall_test.py b/test/unit/objects/firewall_test.py index f4c6efb66..24c6c3656 100644 --- a/test/unit/objects/firewall_test.py +++ b/test/unit/objects/firewall_test.py @@ -20,6 +20,8 @@ def test_get_rules(self): self.assertEqual(rules.inbound_policy, "DROP") self.assertEqual(len(rules.outbound), 0) self.assertEqual(rules.outbound_policy, "DROP") + self.assertEqual(rules.version, 2) + self.assertEqual(rules.fingerprint, "4ef67a29") def test_update_rules(self): """ From dfc536ee50f328c153502ed7bf718e3fc28c9a33 Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Tue, 12 May 2026 13:58:24 -0400 Subject: [PATCH 27/28] TPT-4113: Project SLADE + CLEO (#696) * TPT-4527: Implemented changes for SLADE and CLEO projects (#665) * Implemented changes for SLADE and CLEO projects * Address CoPilot suggestions * Reference newer debian version in examples * Fixed boot_size in unit tests * Add authorized_users as one of the required params alongside root_pass and authorized_keys * Fix lint * Fix docstring * Address suggestions * Drop root_pass from returned fields for instance_create, rebuild, and disk_create * Fix lint * TPT-4261: Implement integration tests for Expand Create Linodes Options and Password-less Linodes (#693) * Create tests test_expected_error_if_fields_authorized_users_authorized_keys_root_pass_are_not_set and def test_create_linode_with_kernel_and_boot_size_then_add_disk_and_rebuild * Add copilot suggestions * Add code review suggestions --------- Co-authored-by: Pawel <100145168+psnoch-akamai@users.noreply.github.com> --- linode_api4/groups/linode.py | 83 +++++++---- linode_api4/objects/linode.py | 60 ++++---- test/integration/conftest.py | 18 ++- .../linode_client/test_linode_client.py | 6 +- .../models/account/test_account.py | 3 +- .../models/firewall/test_firewall.py | 8 +- test/integration/models/linode/test_linode.py | 131 ++++++++++++++--- test/integration/models/lock/test_lock.py | 3 +- .../models/networking/test_networking.py | 5 +- .../models/nodebalancer/test_nodebalancer.py | 3 +- .../models/sharegroups/test_sharegroups.py | 3 +- .../models/volume/test_blockstorage.py | 3 +- test/integration/models/volume/test_volume.py | 3 +- test/unit/linode_client_test.py | 135 +++++++++++++++++- test/unit/objects/linode_test.py | 90 ++++++++++-- 15 files changed, 449 insertions(+), 105 deletions(-) diff --git a/linode_api4/groups/linode.py b/linode_api4/groups/linode.py index a433a0dcf..e9f3da148 100644 --- a/linode_api4/groups/linode.py +++ b/linode_api4/groups/linode.py @@ -162,6 +162,10 @@ def instance_create( interface_generation: Optional[Union[InterfaceGeneration, str]] = None, network_helper: Optional[bool] = None, maintenance_policy: Optional[str] = None, + root_pass: Optional[str] = None, + kernel: Optional[str] = None, + boot_size: Optional[int] = None, + authorized_users: Optional[List[str]] = None, **kwargs, ): """ @@ -172,27 +176,26 @@ def instance_create( 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. + When an Image is provided, at least one of ``root_pass``, ``authorized_users``, or + ``authorized_keys`` must also be given. For example:: - new_linode, password = client.linode.instance_create( + new_linode = client.linode.instance_create( "g6-standard-2", "us-east", - image="linode/debian9") + image="linode/debian13", + root_pass="aComplex@Password123") ltype = client.linode.types().first() region = client.regions().first() image = client.images().first() - another_linode, password = client.linode.instance_create( + another_linode = client.linode.instance_create( ltype, region, - image=image) - - To output the password from the above example: - print(password) + image=image, + authorized_keys="ssh-rsa AAAA") To output the first IPv4 address of the new Linode: print(new_linode.ipv4[0]) @@ -210,10 +213,11 @@ def instance_create( stackscript = StackScript(client, 10079) - new_linode, password = client.linode.instance_create( + new_linode = client.linode.instance_create( "g6-standard-2", "us-east", - image="linode/debian9", + image="linode/debian13", + root_pass="aComplex@Password123", stackscript=stackscript, stackscript_data={"gh_username": "example"}) @@ -244,10 +248,11 @@ def instance_create( 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( + linode = client.linode.instance_create( "g6-standard-1", "us-mia", image="linode/ubuntu24.04", + root_pass="aComplex@Password123", # This can be configured as an account-wide default interface_generation=InterfaceGeneration.LINODE, @@ -280,10 +285,14 @@ def instance_create( :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. + :param image: The Image to deploy to this Instance. If this is provided, + at least one of root_pass, authorized_users, or authorized_keys must also be + provided. :type image: str or Image + :param root_pass: The root password for the new Instance. Required when + an image is provided and neither authorized_users nor + authorized_keys are given. + :type root_pass: str :param stackscript: The StackScript to deploy to the new Instance. If provided, "image" is required and must be compatible with the chosen StackScript. @@ -300,6 +309,11 @@ def instance_create( be a single key, or a path to a file containing the key. :type authorized_keys: list or str + :param authorized_users: A list of usernames whose keys should be installed + as trusted for the root user. These user's keys + should already be set up, see :any:`ProfileGroup.ssh_keys` + for details. + :type authorized_users: list[str] :param label: The display label for the new Instance :type label: str :param group: The display group for the new Instance @@ -335,26 +349,39 @@ def instance_create( :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. If not provided, the default policy (linode/migrate) will be applied. :type maintenance_policy: str - - :returns: A new Instance object, or a tuple containing the new Instance and - the generated password. - :rtype: Instance or tuple(Instance, str) + :param kernel: The kernel to boot the Instance with. If provided, this will be used as the + kernel for the default configuration profile. + :type kernel: str + :param boot_size: The size of the boot disk in MB. If provided, this will be used to create + the boot disk for the Instance. + :type boot_size: int + + :returns: A new Instance object + :rtype: Instance :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 + if ( + image + and not root_pass + and not authorized_keys + and not authorized_users + ): + raise ValueError( + "When creating an Instance from an Image, at least one of " + "root_pass, authorized_users, or authorized_keys must be provided." + ) params = { "type": ltype, "region": region, "image": image, + "root_pass": root_pass, "authorized_keys": load_and_validate_keys(authorized_keys), + "authorized_users": authorized_users, # These will automatically be flattened below "firewall_id": firewall, "backup_id": backup, @@ -372,6 +399,8 @@ def instance_create( "interfaces": interfaces, "interface_generation": interface_generation, "network_helper": network_helper, + "kernel": kernel, + "boot_size": boot_size, } params.update(kwargs) @@ -386,10 +415,7 @@ def instance_create( "Unexpected response when creating linode!", json=result ) - l = Instance(self.client, result["id"], result) - if not ret_pass: - return l - return l, ret_pass + return Instance(self.client, result["id"], result) @staticmethod def build_instance_metadata(user_data=None, encode_user_data=True): @@ -398,10 +424,11 @@ def build_instance_metadata(user_data=None, encode_user_data=True): 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( + new_linode = client.linode.instance_create( "g6-standard-2", "us-east", image="linode/ubuntu22.04", + root_pass="aComplex@Password123", metadata=client.linode.build_instance_metadata(user_data="myuserdata") ) :param user_data: User-defined data to provide to the Linode Instance through diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index bf2f12717..f27fac472 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1437,11 +1437,10 @@ def disk_create( for the image deployed the disk will be used. Required if creating a disk without an image. :param read_only: If True, creates a read-only disk - :param image: The Image to deploy to the disk. + :param image: The Image to deploy to the disk. If provided, at least one of + root_pass, authorized_users or authorized_keys must also be given. :param root_pass: The password to configure for the root user when deploying an - image to this disk. Not used if image is not given. If an - image is given and root_pass is not, a password will be - generated and returned alongside the new disk. + image to this disk. Not used if image is not given. :param authorized_keys: A list of SSH keys to install as trusted for the root user. :param authorized_users: A list of usernames whose keys should be installed as trusted for the root user. These user's keys @@ -1453,12 +1452,21 @@ def disk_create( disk. Requires deploying a compatible image. :param **stackscript_args: Any arguments to pass to the StackScript, as defined by its User Defined Fields. + + :returns: A new Disk object. + :rtype: Disk """ - gen_pass = None - if image and not root_pass: - gen_pass = Instance.generate_root_password() - root_pass = gen_pass + if ( + image + and not root_pass + and not authorized_keys + and not authorized_users + ): + raise ValueError( + "When creating a Disk from an Image, at least one of " + "root_pass, authorized_users, or authorized_keys must be provided." + ) authorized_keys = load_and_validate_keys(authorized_keys) @@ -1505,11 +1513,7 @@ def disk_create( "Unexpected response creating disk!", json=result ) - d = Disk(self._client, result["id"], self.id, result) - - if gen_pass: - return d, gen_pass - return d + return Disk(self._client, result["id"], self.id, result) def enable_backups(self): """ @@ -1621,6 +1625,7 @@ def rebuild( disk_encryption: Optional[ Union[InstanceDiskEncryptionType, str] ] = None, + authorized_users: Optional[List[str]] = None, **kwargs, ): """ @@ -1632,25 +1637,30 @@ def 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 - omitted, a password will be generated and returned. + :param root_pass: The root password for the newly rebuilt Instance. At least + one of root_pass, authorized_users, or authorized_keys must be provided. :type root_pass: str :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 authorized_users: A list of usernames whose keys should be installed + as trusted for the root user. These user's keys + should already be set up, see :any:`ProfileGroup.ssh_keys` + for details. + :type authorized_users: list[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) - :rtype: str or bool + :returns: True. + :rtype: bool """ - ret_pass = None - if not root_pass: - ret_pass = Instance.generate_root_password() - root_pass = ret_pass + if not root_pass and not authorized_keys and not authorized_users: + raise ValueError( + "When rebuilding an Instance, at least one of " + "root_pass, authorized_users, or authorized_keys must be provided." + ) authorized_keys = load_and_validate_keys(authorized_keys) @@ -1661,6 +1671,7 @@ def rebuild( "disk_encryption": ( str(disk_encryption) if disk_encryption else None ), + "authorized_users": authorized_users, } params.update(kwargs) @@ -1679,10 +1690,7 @@ def rebuild( # update ourself with the newly-returned information self._populate(result) - if not ret_pass: - return True - else: - return ret_pass + return True def rescue(self, *disks): """ diff --git a/test/integration/conftest.py b/test/integration/conftest.py index 0058dfcec..74c7a8fd5 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -222,12 +222,13 @@ def create_linode(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance @@ -241,13 +242,15 @@ def create_linode_for_pass_reset(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) + password = "aComplex@Password123" - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass=password, ) yield linode_instance, password @@ -487,15 +490,16 @@ def create_vpc_with_subnet_and_linode( label = get_test_label(length=8) - instance, password = test_linode_client.linode.instance_create( + instance = test_linode_client.linode.instance_create( "g6-standard-1", vpc.region, image="linode/debian11", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) - yield vpc, subnet, instance, password + yield vpc, subnet, instance instance.delete() @@ -578,12 +582,13 @@ def linode_for_vlan_tests(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Vlans"}, site_type="core") label = get_test_label(length=8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance @@ -627,13 +632,14 @@ def linode_with_linode_interfaces( region = vpc.region label = get_test_label() - instance, _ = client.linode.instance_create( + instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, booted=False, interface_generation=InterfaceGeneration.LINODE, + root_pass="aComplex@Password123", interfaces=[ LinodeInterfaceOptions( firewall_id=e2e_test_firewall.id, diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index b855c4bf2..762462220 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -16,12 +16,13 @@ def setup_client_and_linode(test_linode_client, e2e_test_firewall): label = get_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield client, linode_instance @@ -255,7 +256,7 @@ def test_create_linode_with_interfaces(test_linode_client): region = get_region(client, {"Vlans", "Linodes"}, site_type="core").id label = get_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, label=label, @@ -266,6 +267,7 @@ def test_create_linode_with_interfaces(test_linode_client): purpose="vlan", label="cool-vlan", ipam_address="10.0.0.4/32" ), ], + root_pass="aComplex@Password123", ) assert len(linode_instance.configs[0].interfaces) == 2 diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 4c4dcc134..2bb3c48f0 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -98,12 +98,13 @@ def test_latest_get_event(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() - linode, password = client.linode.instance_create( + linode = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) def get_linode_status(): diff --git a/test/integration/models/firewall/test_firewall.py b/test/integration/models/firewall/test_firewall.py index 6bc5a9988..9ccd90b05 100644 --- a/test/integration/models/firewall/test_firewall.py +++ b/test/integration/models/firewall/test_firewall.py @@ -13,8 +13,12 @@ def linode_fw(test_linode_client): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() - linode_instance, password = client.linode.instance_create( - "g6-nanode-1", region, image="linode/debian12", label=label + linode_instance = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + root_pass="aComplex@Password123", ) yield linode_instance diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index bc642ab21..f73fbfc0a 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -37,11 +37,12 @@ def linode_with_volume_firewall(test_linode_client): "inbound_policy": "DROP", } - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label + "_modlinode", + root_pass="aComplex@Password123", ) volume = client.volume_create( @@ -75,13 +76,14 @@ def linode_for_legacy_interface_tests(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, interface_generation=InterfaceGeneration.LEGACY_CONFIG, + root_pass="aComplex@Password123", ) yield linode_instance @@ -97,7 +99,7 @@ def linode_and_vpc_for_legacy_interface_tests_offline( label = get_test_label(length=8) - instance, password = test_linode_client.linode.instance_create( + instance = test_linode_client.linode.instance_create( "g6-standard-1", vpc.region, booted=False, @@ -105,9 +107,10 @@ def linode_and_vpc_for_legacy_interface_tests_offline( label=label, firewall=e2e_test_firewall, interface_generation=InterfaceGeneration.LEGACY_CONFIG, + root_pass="aComplex@Password123", ) - yield vpc, subnet, instance, password + yield vpc, subnet, instance instance.delete() @@ -119,12 +122,13 @@ def linode_for_vpu_tests(test_linode_client, e2e_test_firewall): label = get_test_label(length=8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g1-accelerated-netint-vpu-t1u1-s", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance @@ -138,12 +142,13 @@ def linode_for_disk_tests(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/ubuntu24.04", label=label + "_long_tests", firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) # Provisioning time @@ -171,12 +176,13 @@ def linode_with_block_storage_encryption(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Block Storage Encryption"}) label = get_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/ubuntu24.04", label=label + "block-storage-encryption", firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance @@ -190,12 +196,13 @@ def create_linode_for_long_running_tests(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label + "_long_tests", firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance @@ -212,13 +219,36 @@ def linode_with_disk_encryption(test_linode_client, request): disk_encryption = request.param - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", target_region, image="linode/ubuntu24.04", label=label, booted=False, disk_encryption=disk_encryption, + root_pass="aComplex@Password123", + ) + + yield linode_instance + + linode_instance.delete() + + +@pytest.fixture(scope="session") +def create_linode_with_authorized_key(test_linode_client, ssh_key_gen): + 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, + kernel="linode/latest-64bit", + boot_size=9000, + authorized_keys=ssh_key_gen[0], ) yield linode_instance @@ -266,8 +296,12 @@ def test_linode_rebuild(test_linode_client): label = get_test_label() + "_rebuild" - linode, password = client.linode.instance_create( - "g6-nanode-1", region, image="linode/debian12", label=label + linode = client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + root_pass="aComplex@Password123", ) wait_for_condition(10, 100, get_status, linode, "running") @@ -276,6 +310,7 @@ def test_linode_rebuild(test_linode_client): 3, linode.rebuild, "linode/debian12", + root_pass="aComplex@Password123", disk_encryption=InstanceDiskEncryptionType.disabled, ) @@ -322,11 +357,12 @@ def test_delete_linode(test_linode_client): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label + "_linode", + root_pass="aComplex@Password123", ) linode_instance.delete() @@ -595,12 +631,13 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() + "_migration" - linode, _ = client.linode.instance_create( + linode = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) # Says it could take up to ~6 hrs for migration to fully complete @@ -626,7 +663,7 @@ 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 + vpc, subnet, linode = linode_and_vpc_for_legacy_interface_tests_offline config = linode.configs[0] new_interfaces = [ @@ -918,9 +955,7 @@ def test_create_vpc( test_linode_client, linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = ( - linode_and_vpc_for_legacy_interface_tests_offline - ) + vpc, subnet, linode = linode_and_vpc_for_legacy_interface_tests_offline config: Config = linode.configs[0] @@ -1028,9 +1063,7 @@ def test_update_vpc( self, linode_and_vpc_for_legacy_interface_tests_offline, ): - vpc, subnet, linode, _ = ( - linode_and_vpc_for_legacy_interface_tests_offline - ) + vpc, subnet, linode = linode_and_vpc_for_legacy_interface_tests_offline config: Config = linode.configs[0] @@ -1091,7 +1124,7 @@ def test_reorder(self, linode_for_legacy_interface_tests): def test_delete_interface_containing_vpc( self, create_vpc_with_subnet_and_linode ): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode = create_vpc_with_subnet_and_linode config: Config = linode.configs[0] @@ -1125,12 +1158,13 @@ def test_create_linode_with_maintenance_policy(test_linode_client): 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( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label + "_with_policy", maintenance_policy=non_default_policy.slug, + root_pass="aComplex@Password123", ) assert linode_instance.id is not None @@ -1156,3 +1190,56 @@ def test_update_linode_maintenance_policy(create_linode, test_linode_client): linode.invalidate() assert result assert linode.maintenance_policy_id == non_default_policy.slug + + +def test_expected_error_if_fields_authorized_users_authorized_keys_root_pass_are_not_set( + test_linode_client, +): + client = test_linode_client + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") + label = get_test_label(length=8) + + with pytest.raises(ValueError) as create_instance_error: + client.linode.instance_create( + "g6-nanode-1", + region, + image="linode/debian12", + label=label, + kernel="linode/latest-64bit", + boot_size=9000, + ) + assert ( + "When creating an Instance from an Image, at least one of root_pass, authorized_users, or authorized_keys must be provided." + in str(create_instance_error.value) + ) + + +def test_create_linode_with_kernel_and_boot_size_then_add_disk_and_rebuild( + create_linode_with_authorized_key, + ssh_key_gen, +): + linode_create = create_linode_with_authorized_key + assert linode_create.image.id == "linode/debian12" + + wait_for_condition(10, 300, get_status, linode_create, "running") + disk_create = send_request_when_resource_available( + 300, + linode_create.disk_create, + size=2000, + image="linode/debian12", + label="python-disk-test-" + get_test_label(), + root_pass="aComplex@Password123", + ) + wait_for_disk_status(disk_create, 120) + assert disk_create.status == "ready" + + retry_sending_request( + 3, + linode_create.rebuild, + "linode/debian12", + authorized_keys=ssh_key_gen[0], + ) + wait_for_condition(10, 300, get_status, linode_create, "rebuilding") + assert linode_create.status == "rebuilding" + wait_for_condition(10, 300, get_status, linode_create, "running") + assert linode_create.image.id == "linode/debian12" diff --git a/test/integration/models/lock/test_lock.py b/test/integration/models/lock/test_lock.py index f2139a176..31f89b992 100644 --- a/test/integration/models/lock/test_lock.py +++ b/test/integration/models/lock/test_lock.py @@ -18,12 +18,13 @@ def linode_for_lock(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) - linode_instance, _ = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 27ffbb444..47eeaf0e6 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -37,11 +37,12 @@ def create_linode_func(test_linode_client): label = get_test_label() - linode_instance, _ = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", TEST_REGION, image="linode/debian12", label=label, + root_pass="aComplex@Password123", ) return linode_instance @@ -220,7 +221,7 @@ def test_ip_addresses_unshare( def test_ip_info_vpc(test_linode_client, create_vpc_with_subnet_and_linode): - vpc, subnet, linode, _ = create_vpc_with_subnet_and_linode + vpc, subnet, linode = create_vpc_with_subnet_and_linode config: Config = linode.configs[0] diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 692efb027..039259c68 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -33,13 +33,14 @@ def linode_with_private_ip(test_linode_client, e2e_test_firewall): client = test_linode_client label = get_test_label(8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", TEST_REGION, image="linode/debian12", label=label, private_ip=True, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance diff --git a/test/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py index 776454c9a..1779c0469 100644 --- a/test/integration/models/sharegroups/test_sharegroups.py +++ b/test/integration/models/sharegroups/test_sharegroups.py @@ -39,11 +39,12 @@ def sample_linode(test_linode_client, e2e_test_firewall): region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label(length=8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", region, image="linode/ubuntu24.04", label=label + "_modlinode", + root_pass="aComplex@Password123", ) yield linode_instance linode_instance.delete() diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py index e382f4a2a..089d9f78d 100644 --- a/test/integration/models/volume/test_blockstorage.py +++ b/test/integration/models/volume/test_blockstorage.py @@ -8,11 +8,12 @@ def test_config_create_with_extended_volume_limit(test_linode_client): region = get_region(client, {"Linodes", "Block Storage"}, site_type="core") label = get_test_label() - linode, _ = client.linode.instance_create( + linode = client.linode.instance_create( "g6-standard-6", region, image="linode/debian12", label=label, + root_pass="aComplex@Password123", ) volumes = [ diff --git a/test/integration/models/volume/test_volume.py b/test/integration/models/volume/test_volume.py index 56395d203..7f9045e2e 100644 --- a/test/integration/models/volume/test_volume.py +++ b/test/integration/models/volume/test_volume.py @@ -46,12 +46,13 @@ def linode_for_volume(test_linode_client, e2e_test_firewall): label = get_test_label(length=8) - linode_instance, password = client.linode.instance_create( + linode_instance = client.linode.instance_create( "g6-nanode-1", TEST_REGION, image="linode/debian12", label=label, firewall=e2e_test_firewall, + root_pass="aComplex@Password123", ) yield linode_instance diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 4d0db5753..41b5637dd 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -686,13 +686,140 @@ def test_instance_create(self): def test_instance_create_with_image(self): """ - Tests that a Linode Instance can be created with an image, and a password generated + Tests that a Linode Instance can be created with an image and root_pass """ with self.mock_post("linode/instances/123") as m: - l, pw = self.client.linode.instance_create( + l = self.client.linode.instance_create( + "g6-standard-1", + "us-east-1a", + image="linode/debian9", + root_pass="aComplex@Password123", + ) + + self.assertIsNotNone(l) + self.assertEqual(l.id, 123) + + self.assertEqual(m.call_url, "/linode/instances") + + self.assertEqual( + m.call_data, + { + "region": "us-east-1a", + "type": "g6-standard-1", + "image": "linode/debian9", + "root_pass": "aComplex@Password123", + }, + ) + + def test_instance_create_with_image_authorized_keys(self): + """ + Tests that a Linode Instance can be created with an image and authorized_keys only + """ + with self.mock_post("linode/instances/123") as m: + l = self.client.linode.instance_create( + "g6-standard-1", + "us-east-1a", + image="linode/debian9", + authorized_keys="ssh-rsa AAAA", + ) + + self.assertIsNotNone(l) + self.assertEqual(l.id, 123) + + self.assertEqual(m.call_url, "/linode/instances") + + self.assertEqual( + m.call_data, + { + "region": "us-east-1a", + "type": "g6-standard-1", + "image": "linode/debian9", + "authorized_keys": ["ssh-rsa AAAA"], + }, + ) + + def test_instance_create_with_image_requires_auth(self): + """ + Tests that creating an Instance from an Image without root_pass or + authorized_keys raises a ValueError + """ + with self.assertRaises(ValueError): + self.client.linode.instance_create( "g6-standard-1", "us-east-1a", image="linode/debian9" ) + def test_instance_create_with_kernel(self): + """ + Tests that a Linode Instance can be created with a kernel + """ + with self.mock_post("linode/instances/123") as m: + l = self.client.linode.instance_create( + "g6-standard-1", + "us-east-1a", + image="linode/debian9", + root_pass="aComplex@Password123", + kernel="linode/latest-64bit", + ) + + self.assertIsNotNone(l) + self.assertEqual(l.id, 123) + + self.assertEqual(m.call_url, "/linode/instances") + + self.assertEqual( + m.call_data, + { + "region": "us-east-1a", + "type": "g6-standard-1", + "image": "linode/debian9", + "root_pass": "aComplex@Password123", + "kernel": "linode/latest-64bit", + }, + ) + + def test_instance_create_with_boot_size(self): + """ + Tests that a Linode Instance can be created with a boot_size + """ + with self.mock_post("linode/instances/123") as m: + l = self.client.linode.instance_create( + "g6-standard-1", + "us-east-1a", + image="linode/debian9", + root_pass="aComplex@Password123", + boot_size=8192, + ) + + self.assertIsNotNone(l) + self.assertEqual(l.id, 123) + + self.assertEqual(m.call_url, "/linode/instances") + + self.assertEqual( + m.call_data, + { + "region": "us-east-1a", + "type": "g6-standard-1", + "image": "linode/debian9", + "root_pass": "aComplex@Password123", + "boot_size": 8192, + }, + ) + + def test_instance_create_with_kernel_and_boot_size(self): + """ + Tests that a Linode Instance can be created with both kernel and boot_size + """ + with self.mock_post("linode/instances/123") as m: + l = self.client.linode.instance_create( + "g6-standard-1", + "us-east-1a", + image="linode/debian9", + root_pass="aComplex@Password123", + kernel="linode/latest-64bit", + boot_size=8192, + ) + self.assertIsNotNone(l) self.assertEqual(l.id, 123) @@ -704,7 +831,9 @@ def test_instance_create_with_image(self): "region": "us-east-1a", "type": "g6-standard-1", "image": "linode/debian9", - "root_pass": pw, + "root_pass": "aComplex@Password123", + "kernel": "linode/latest-64bit", + "boot_size": 8192, }, ) diff --git a/test/unit/objects/linode_test.py b/test/unit/objects/linode_test.py index 1c31f8109..b9a6287e2 100644 --- a/test/unit/objects/linode_test.py +++ b/test/unit/objects/linode_test.py @@ -88,30 +88,62 @@ def test_transfer(self): def test_rebuild(self): """ - Tests that you can rebuild with an image + Tests that you can rebuild with an image and root_pass """ linode = Instance(self.client, 123) with self.mock_post("/linode/instances/123") as m: - pw = linode.rebuild( + linode.rebuild( "linode/debian9", + root_pass="aComplex@Password123", disk_encryption=InstanceDiskEncryptionType.enabled, ) - self.assertIsNotNone(pw) - self.assertTrue(isinstance(pw, str)) - self.assertEqual(m.call_url, "/linode/instances/123/rebuild") self.assertEqual( m.call_data, { "image": "linode/debian9", - "root_pass": pw, + "root_pass": "aComplex@Password123", "disk_encryption": "enabled", }, ) + def test_rebuild_with_authorized_keys(self): + """ + Tests that you can rebuild with an image and authorized_keys only + """ + linode = Instance(self.client, 123) + + with self.mock_post("/linode/instances/123") as m: + result = linode.rebuild( + "linode/debian9", + authorized_keys="ssh-rsa AAAA", + ) + + self.assertTrue(result) + + self.assertEqual(m.call_url, "/linode/instances/123/rebuild") + + self.assertEqual( + m.call_data, + { + "image": "linode/debian9", + "authorized_keys": ["ssh-rsa AAAA"], + }, + ) + + def test_rebuild_requires_auth(self): + """ + Tests that rebuild raises ValueError when neither root_pass nor + authorized_keys is provided + """ + linode = Instance(self.client, 123) + + with self.assertRaises(ValueError): + linode.rebuild("linode/debian9") + def test_available_backups(self): """ Tests that a Linode can retrieve its own backups @@ -437,11 +469,12 @@ 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( + disk = linode.disk_create( 1234, label="test", authorized_users=["test"], image="linode/debian12", + root_pass="aComplex@Password123", ) self.assertEqual(m.call_url, "/linode/instances/123/disks") self.assertEqual( @@ -449,7 +482,7 @@ def test_create_disk(self): { "size": 1234, "label": "test", - "root_pass": gen_pass, + "root_pass": "aComplex@Password123", "image": "linode/debian12", "authorized_users": ["test"], "read_only": False, @@ -459,6 +492,47 @@ def test_create_disk(self): assert disk.id == 12345 assert disk.disk_encryption == InstanceDiskEncryptionType.disabled + def test_create_disk_with_authorized_keys(self): + """ + Tests that disk_create works with authorized_keys and no root_pass + """ + linode = Instance(self.client, 123) + + with self.mock_post("/linode/instances/123/disks/12345") as m: + disk = linode.disk_create( + 1234, + label="test", + image="linode/debian12", + authorized_keys="ssh-rsa AAAA", + ) + self.assertEqual(m.call_url, "/linode/instances/123/disks") + self.assertEqual( + m.call_data, + { + "size": 1234, + "label": "test", + "image": "linode/debian12", + "authorized_keys": ["ssh-rsa AAAA"], + "read_only": False, + }, + ) + + assert disk.id == 12345 + + def test_create_disk_with_image_requires_auth(self): + """ + Tests that disk_create raises ValueError when image is provided + without root_pass or authorized_keys + """ + linode = Instance(self.client, 123) + + with self.assertRaises(ValueError): + linode.disk_create( + 1234, + label="test", + image="linode/debian12", + ) + def test_create_config_with_device_map(self): """ Tests that config_create passes through a raw device map unchanged. From fcc41e560fea615e9f2574099a275e1ef3083e0b Mon Sep 17 00:00:00 2001 From: Erik Zilber Date: Wed, 13 May 2026 09:30:55 -0400 Subject: [PATCH 28/28] Fix failing integration test (#697) * Fix failing integration test * Use random root pass * Fix lint --- test/integration/models/volume/test_blockstorage.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/models/volume/test_blockstorage.py b/test/integration/models/volume/test_blockstorage.py index 089d9f78d..69a60868b 100644 --- a/test/integration/models/volume/test_blockstorage.py +++ b/test/integration/models/volume/test_blockstorage.py @@ -1,6 +1,8 @@ from test.integration.conftest import get_region from test.integration.helpers import get_test_label, retry_sending_request +from linode_api4 import Instance + def test_config_create_with_extended_volume_limit(test_linode_client): client = test_linode_client @@ -47,11 +49,12 @@ def test_config_create_with_device_map(test_linode_client): region = get_region(client, {"Linodes", "Block Storage"}, site_type="core") label = get_test_label() - linode, _ = client.linode.instance_create( + linode = client.linode.instance_create( "g6-standard-6", region, image="linode/debian12", label=label, + root_pass=Instance.generate_root_password(), ) disk_id = linode.disks[0].id