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/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 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: 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/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/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/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/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/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index b8b0fc9dc..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): """ @@ -2055,8 +2063,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 +2138,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 +2206,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/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}" 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/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 """ 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/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": [ { 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/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/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 4060064d3..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 @@ -116,12 +117,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): @@ -258,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, @@ -269,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 16805f3b8..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 @@ -29,6 +33,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 +69,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/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 512b6c513..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/alpine3.19", + 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/alpine3.19", + 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.10", + 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/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/integration/models/sharegroups/test_sharegroups.py b/test/integration/models/sharegroups/test_sharegroups.py index 9c66bad90..1779c0469 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 @@ -41,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/alpine3.19", + 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..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 @@ -8,11 +10,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 = [ @@ -46,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 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 e82f3562d..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, }, ) @@ -1407,13 +1536,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") 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): """ 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. 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)