diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 5bea77b2c..d97f93452 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,7 +7,3 @@ **What are the steps to reproduce the issue or verify the changes?** **How do I run the relevant unit/integration tests?** - -## 📷 Preview - -**If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** \ No newline at end of file diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 86809d177..f765b0a0d 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -27,6 +27,14 @@ on: pull_request_number: description: 'The number of the PR.' required: false + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' name: PR E2E Tests @@ -101,7 +109,7 @@ jobs: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - name: Upload test results - if: always() + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index e2762ff95..df1a41841 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -41,6 +41,14 @@ on: options: - 'true' - 'false' + test_report_upload: + description: 'Indicates whether to upload the test report to object storage. Defaults to "false"' + type: choice + required: false + default: 'false' + options: + - 'true' + - 'false' push: branches: - main @@ -172,7 +180,8 @@ jobs: process-upload-report: runs-on: ubuntu-latest needs: [integration-tests] - if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + # Run even if integration tests fail on main repository AND push event OR test_report_upload is true in case of manual run + if: always() && github.repository == 'linode/linode_api4-python' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.test_report_upload == 'true')) outputs: summary: ${{ steps.set-test-summary.outputs.summary }} @@ -184,7 +193,7 @@ jobs: submodules: 'recursive' - name: Download test report - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: test-report-file @@ -271,4 +280,4 @@ jobs: payload: | channel: ${{ secrets.SLACK_CHANNEL_ID }} thread_ts: "${{ steps.main_message.outputs.ts }}" - text: "${{ needs.process-upload-report.outputs.summary }}" + text: "${{ needs.process-upload-report.outputs.summary }}" \ No newline at end of file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 843a41c4d..14e770b11 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v6 - name: Run Labeler - uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 + uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d with: github-token: ${{ secrets.GITHUB_TOKEN }} yaml-file: .github/labels.yml diff --git a/CODEOWNERS b/CODEOWNERS index 69cb641ca..e023b0d14 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,2 +1 @@ -* @linode/dx - +* @linode/dx @linode/dx-sdets 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 f88808e64..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 @@ -335,8 +335,6 @@ def instance_create( :type network_helper: bool :param maintenance_policy: The slug of the maintenance policy to apply during maintenance. If not provided, the default policy (linode/migrate) will be applied. - NOTE: This field is in beta and may only - function if base_url is set to `https://api.linode.com/v4beta`. :type maintenance_policy: str :returns: A new Instance object, or a tuple containing the new Instance and @@ -459,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/maintenance.py b/linode_api4/groups/maintenance.py index 7d56cec6e..63cb424df 100644 --- a/linode_api4/groups/maintenance.py +++ b/linode_api4/groups/maintenance.py @@ -9,8 +9,6 @@ class MaintenanceGroup(Group): def maintenance_policies(self): """ - .. note:: This endpoint is in beta. This will only function if base_url is set to `https://api.linode.com/v4beta`. - Returns a collection of MaintenancePolicy objects representing available maintenance policies that can be applied to Linodes diff --git a/linode_api4/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/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/__init__.py b/linode_api4/objects/__init__.py index 009e9436e..89a681635 100644 --- a/linode_api4/objects/__init__.py +++ b/linode_api4/objects/__init__.py @@ -3,7 +3,7 @@ from .dbase import DerivedBase from .serializable import JSONObject from .filtering import and_, or_ -from .region import Region +from .region import Region, Capability from .image import Image from .linode import * from .linode_interfaces import * diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 54298ed11..a4aca1848 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -218,9 +218,7 @@ class AccountSettings(Base): "object_storage": Property(), "backups_enabled": Property(mutable=True), "interfaces_for_new_linodes": Property(mutable=True), - "maintenance_policy": Property( - mutable=True - ), # Note: This field is only available when using v4beta. + "maintenance_policy": Property(mutable=True), } @@ -249,7 +247,7 @@ class Event(Base): "duration": Property(), "secondary_entity": Property(), "message": Property(), - "maintenance_policy_set": Property(), # Note: This field is only available when using v4beta. + "maintenance_policy_set": Property(), "description": Property(), "source": Property(), "not_before": Property(is_datetime=True), diff --git a/linode_api4/objects/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/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index fae0926d5..3ffe4b232 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -1,6 +1,7 @@ import copy import string import sys +import warnings from dataclasses import dataclass, field from datetime import datetime from enum import Enum @@ -39,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): @@ -217,7 +221,7 @@ def resize(self, new_size): class Kernel(Base): """ The primary component of every Linux system. The kernel interfaces - with the system’s hardware and it controls the operating system’s core functionality. + with the system’s hardware, and it controls the operating system’s core functionality. Your Compute Instance is capable of running one of three kinds of kernels: @@ -237,6 +241,10 @@ class Kernel(Base): to compile the kernel from source than to download it from your package manager. For more information on custom compiled kernels, review our guides for Debian, Ubuntu, and CentOS. + .. note:: + The ``xen`` property is deprecated and is no longer returned by the API. + It is maintained for backward compatibility only. + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-kernel """ @@ -256,6 +264,16 @@ class Kernel(Base): "pvops": Property(), } + def __getattribute__(self, name: str) -> object: + if name == "xen": + warnings.warn( + "The 'xen' property of Kernel is deprecated and is no longer " + "returned by the API. It is maintained for backward compatibility only.", + DeprecationWarning, + stacklevel=2, + ) + return super().__getattribute__(name) + class Type(Base): """ @@ -800,9 +818,7 @@ class Instance(Base): "lke_cluster_id": Property(), "capabilities": Property(unordered=True), "interface_generation": Property(), - "maintenance_policy": Property( - mutable=True - ), # Note: This field is only available when using v4beta. + "maintenance_policy": Property(mutable=True), "locks": Property(unordered=True), } @@ -1259,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/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/linode_api4/objects/monitor.py b/linode_api4/objects/monitor.py index 4315e4c2e..ca8f83921 100644 --- a/linode_api4/objects/monitor.py +++ b/linode_api4/objects/monitor.py @@ -116,6 +116,19 @@ class DashboardType(StrEnum): custom = "custom" +class AlertStatus(StrEnum): + """ + Enum for supported alert status values. + """ + + AlertDefinitionStatusProvisioning = "provisioning" + AlertDefinitionStatusEnabling = "enabling" + AlertDefinitionStatusDisabling = "disabling" + AlertDefinitionStatusEnabled = "enabled" + AlertDefinitionStatusDisabled = "disabled" + AlertDefinitionStatusFailed = "failed" + + @dataclass class Filter(JSONObject): """ @@ -428,6 +441,40 @@ class ChannelContent(JSONObject): # Other channel types like 'webhook', 'slack' could be added here as Optional fields. +@dataclass +class EmailDetails(JSONObject): + """ + Represents email-specific details for an alert channel. + """ + + usernames: Optional[List[str]] = None + recipient_type: Optional[str] = None + + +@dataclass +class ChannelDetails(JSONObject): + """ + Represents the details block for an AlertChannel, which varies by channel type. + """ + + email: Optional[EmailDetails] = None + + +@dataclass +class AlertInfo(JSONObject): + """ + Represents a reference to alerts associated with an alert channel. + Fields: + - url: str - API URL to fetch the alerts for this channel + - type: str - Type identifier (e.g., 'alerts-definitions') + - alert_count: int - Number of alerts associated with this channel + """ + + url: str = "" + _type: str = field(default="", metadata={"json_key": "type"}) + alert_count: int = 0 + + class AlertChannel(Base): """ Represents an alert channel used to deliver notifications when alerts @@ -450,7 +497,8 @@ class AlertChannel(Base): "label": Property(), "type": Property(), "channel_type": Property(), - "alerts": Property(mutable=False, json_object=Alerts), + "details": Property(mutable=False, json_object=ChannelDetails), + "alerts": Property(mutable=False, json_object=AlertInfo), "content": Property(mutable=False, json_object=ChannelContent), "created": Property(is_datetime=True), "updated": Property(is_datetime=True), diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index f02dda269..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() @@ -252,6 +254,7 @@ class NodeBalancer(Base): "transfer": Property(), "tags": Property(mutable=True, unordered=True), "client_udp_sess_throttle": Property(mutable=True), + "locks": Property(unordered=True), } # create derived objects diff --git a/linode_api4/objects/region.py b/linode_api4/objects/region.py index c9dc05099..9a77dc485 100644 --- a/linode_api4/objects/region.py +++ b/linode_api4/objects/region.py @@ -3,6 +3,68 @@ from linode_api4.errors import UnexpectedResponseError from linode_api4.objects.base import Base, JSONObject, Property +from linode_api4.objects.serializable import StrEnum + + +class Capability(StrEnum): + """ + An enum class that represents the capabilities that Linode offers + across different regions and services. + + These capabilities indicate what services are available in each data center. + """ + + linodes = "Linodes" + nodebalancers = "NodeBalancers" + block_storage = "Block Storage" + object_storage = "Object Storage" + object_storage_regions = "Object Storage Access Key Regions" + object_storage_endpoint_types = "Object Storage Endpoint Types" + lke = "Kubernetes" + lke_ha_controlplanes = "LKE HA Control Planes" + lke_e = "Kubernetes Enterprise" + firewall = "Cloud Firewall" + gpu = "GPU Linodes" + vlans = "Vlans" + vpcs = "VPCs" + vpcs_extra = "VPCs Extra" + machine_images = "Machine Images" + dbaas = "Managed Databases" + dbaas_beta = "Managed Databases Beta" + bs_migrations = "Block Storage Migrations" + metadata = "Metadata" + premium_plans = "Premium Plans" + edge_plans = "Edge Plans" + distributed_plans = "Distributed Plans" + lke_control_plane_acl = "LKE Network Access Control List (IP ACL)" + aclb = "Akamai Cloud Load Balancer" + support_ticket_severity = "Support Ticket Severity" + backups = "Backups" + placement_group = "Placement Group" + disk_encryption = "Disk Encryption" + la_disk_encryption = "LA Disk Encryption" + akamai_ram_protection = "Akamai RAM Protection" + blockstorage_encryption = "Block Storage Encryption" + blockstorage_perf_b1 = "Block Storage Performance B1" + blockstorage_perf_b1_default = "Block Storage Performance B1 Default" + aclp = "Akamai Cloud Pulse" + aclp_logs = "Akamai Cloud Pulse Logs" + aclp_logs_lkee = "Akamai Cloud Pulse Logs LKE-E Audit" + aclp_logs_dc_lkee = "ACLP Logs Datacenter LKE-E" + smtp_enabled = "SMTP Enabled" + stackscripts = "StackScripts" + vpu = "NETINT Quadra T1U" + linode_interfaces = "Linode Interfaces" + maintenance_policy = "Maintenance Policy" + vpc_dual_stack = "VPC Dual Stack" + vpc_ipv6_stack = "VPC IPv6 Stack" + nlb = "Network LoadBalancer" + natgateway = "NAT Gateway" + lke_e_byovpc = "Kubernetes Enterprise BYO VPC" + lke_e_stacktype = "Kubernetes Enterprise Dual Stack" + ruleset = "Cloud Firewall Rule Set" + prefixlists = "Cloud Firewall Prefix Lists" + current_prefixlists = "Cloud Firewall Prefix List Current References" @dataclass @@ -63,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): @@ -75,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/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/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/fixtures/nodebalancers.json b/test/fixtures/nodebalancers.json index 85eec186b..9b4dc8dae 100644 --- a/test/fixtures/nodebalancers.json +++ b/test/fixtures/nodebalancers.json @@ -10,7 +10,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123456", "client_conn_throttle": 0, - "tags": ["something"] + "tags": ["something"], + "locks": ["cannot_delete_with_subresources"] }, { "created": "2018-01-01T00:01:01", @@ -22,7 +23,8 @@ "updated": "2018-01-01T00:01:01", "label": "balancer123457", "client_conn_throttle": 0, - "tags": [] + "tags": [], + "locks": [] } ], "results": 2, diff --git a/test/fixtures/nodebalancers_123456.json b/test/fixtures/nodebalancers_123456.json index e965d4379..a78c8d3e3 100644 --- a/test/fixtures/nodebalancers_123456.json +++ b/test/fixtures/nodebalancers_123456.json @@ -10,5 +10,8 @@ "client_conn_throttle": 0, "tags": [ "something" + ], + "locks": [ + "cannot_delete_with_subresources" ] } \ No newline at end of file diff --git a/test/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/filters/fixtures.py b/test/integration/filters/fixtures.py index 31b7edcbf..e753236dd 100644 --- a/test/integration/filters/fixtures.py +++ b/test/integration/filters/fixtures.py @@ -25,11 +25,11 @@ def lke_cluster_instance(test_linode_client): region = get_region(test_linode_client, {"Kubernetes", "Disk Encryption"}) - node_pools = test_linode_client.lke.node_pool(node_type, 3) + node_pool = test_linode_client.lke.node_pool(node_type, 3) label = get_test_label() + "_cluster" cluster = test_linode_client.lke.cluster_create( - region, label, node_pools, version + region, label, version, [node_pool] ) yield cluster diff --git a/test/integration/filters/model_filters_test.py b/test/integration/filters/model_filters_test.py index 22bb8299e..55bed6ac3 100644 --- a/test/integration/filters/model_filters_test.py +++ b/test/integration/filters/model_filters_test.py @@ -63,12 +63,13 @@ def test_linode_type_model_filter(test_linode_client): def test_lke_cluster_model_filter(test_linode_client, lke_cluster_instance): client = test_linode_client + lke_cluster = lke_cluster_instance filtered_cluster = client.lke.clusters( - LKECluster.label.contains(lke_cluster_instance.label) + LKECluster.label.contains(lke_cluster.label) ) - assert filtered_cluster[0].id == lke_cluster_instance.id + assert filtered_cluster[0].id == lke_cluster.id def test_networking_firewall_model_filter( diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index 9935fc345..4060064d3 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -328,10 +328,10 @@ def test_cluster_create_with_api_objects(test_linode_client): node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] region = get_region(client, {"Kubernetes"}) - node_pools = client.lke.node_pool(node_type, 3) + node_pool = client.lke.node_pool(node_type, 3) label = get_test_label() - cluster = client.lke.cluster_create(region, label, node_pools, version) + cluster = client.lke.cluster_create(region, label, version, [node_pool]) assert cluster.region.id == region.id assert cluster.k8s_version.id == version.id diff --git a/test/integration/models/account/test_account.py b/test/integration/models/account/test_account.py index 5833a9344..4c4dcc134 100644 --- a/test/integration/models/account/test_account.py +++ b/test/integration/models/account/test_account.py @@ -1,7 +1,11 @@ import time from datetime import datetime from test.integration.conftest import get_region -from test.integration.helpers import get_test_label, retry_sending_request +from test.integration.helpers import ( + get_test_label, + retry_sending_request, + wait_for_condition, +) import pytest @@ -102,13 +106,18 @@ def test_latest_get_event(test_linode_client, e2e_test_firewall): firewall=e2e_test_firewall, ) - events = client.load(Event, "") + def get_linode_status(): + return linode.status == "running" - latest_events = events._raw_json.get("data") + # To ensure the Linode is running and the 'event' key has been populated + wait_for_condition(3, 100, get_linode_status) + + events = client.load(Event, "") + latest_events = events._raw_json.get("data")[:15] linode.delete() - for event in latest_events[:15]: + for event in latest_events: if label == event["entity"]["label"]: break else: diff --git a/test/integration/models/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 diff --git a/test/integration/models/domain/test_domain.py b/test/integration/models/domain/test_domain.py index 9dc180a6e..d7956d421 100644 --- a/test/integration/models/domain/test_domain.py +++ b/test/integration/models/domain/test_domain.py @@ -43,10 +43,10 @@ def test_clone(test_linode_client, test_domain): dom = "example.clone-" + timestamp + "-inttestsdk.org" domain.clone(dom) - ds = test_linode_client.domains() - time.sleep(1) + ds = test_linode_client.domains() + domains = [i.domain for i in ds] assert dom in domains diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index c485dd19c..9f6194fa9 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -611,6 +611,12 @@ def test_linode_initate_migration(test_linode_client, e2e_test_firewall): migration_type=MigrationType.COLD, ) + def get_linode_status(): + return linode.status == "offline" + + # To verify that Linode's status changed before deletion (during migration status is set to 'offline') + wait_for_condition(5, 120, get_linode_status) + res = linode.delete() assert res @@ -1101,8 +1107,7 @@ def test_delete_interface_containing_vpc( def test_create_linode_with_maintenance_policy(test_linode_client): client = test_linode_client - # TODO: Replace with random region after GA - region = "ap-south" + region = get_region(client, {"Linodes", "Cloud Firewall"}, site_type="core") label = get_test_label() policies = client.maintenance.maintenance_policies() 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 diff --git a/test/integration/models/monitor/test_monitor.py b/test/integration/models/monitor/test_monitor.py index b6cf40b54..908ac1a44 100644 --- a/test/integration/models/monitor/test_monitor.py +++ b/test/integration/models/monitor/test_monitor.py @@ -16,6 +16,7 @@ MonitorService, MonitorServiceToken, ) +from linode_api4.objects.monitor import AlertStatus # List all dashboards @@ -227,7 +228,8 @@ def wait_for_alert_ready(alert_id, service_type: str): interval = initial_timeout alert = client.load(AlertDefinition, alert_id, service_type) while ( - getattr(alert, "status", None) == "in progress" + getattr(alert, "status", None) + != AlertStatus.AlertDefinitionStatusEnabled and (time.time() - start) < timeout ): time.sleep(interval) diff --git a/test/integration/models/networking/test_networking.py b/test/integration/models/networking/test_networking.py index 0edd5bd0a..27ffbb444 100644 --- a/test/integration/models/networking/test_networking.py +++ b/test/integration/models/networking/test_networking.py @@ -32,7 +32,7 @@ ) -def create_linode(test_linode_client): +def create_linode_func(test_linode_client): client = test_linode_client label = get_test_label() @@ -49,7 +49,7 @@ def create_linode(test_linode_client): @pytest.fixture def create_linode_for_ip_share(test_linode_client): - linode = create_linode(test_linode_client) + linode = create_linode_func(test_linode_client) yield linode @@ -58,7 +58,7 @@ def create_linode_for_ip_share(test_linode_client): @pytest.fixture def create_linode_to_be_shared_with_ips(test_linode_client): - linode = create_linode(test_linode_client) + linode = create_linode_func(test_linode_client) yield linode @@ -302,6 +302,8 @@ def test_create_and_delete_vlan(test_linode_client, linode_for_vlan_tests): wait_for_condition(3, 100, get_status, linode, "rebooting") assert linode.status == "rebooting" + wait_for_condition(3, 100, get_status, linode, "running") + # Delete the VLAN is_deleted = test_linode_client.networking.delete_vlan( vlan_label, linode.region @@ -334,6 +336,7 @@ def test_get_global_firewall_settings(test_linode_client): def test_ip_info(test_linode_client, create_linode): linode = create_linode + wait_for_condition(3, 100, get_status, linode, "running") ip_info = test_linode_client.load(IPAddress, linode.ipv4[0]) diff --git a/test/integration/models/nodebalancer/test_nodebalancer.py b/test/integration/models/nodebalancer/test_nodebalancer.py index 9e7537897..692efb027 100644 --- a/test/integration/models/nodebalancer/test_nodebalancer.py +++ b/test/integration/models/nodebalancer/test_nodebalancer.py @@ -130,7 +130,7 @@ def test_get_nb_config_with_udp(test_linode_client, create_nb_config_with_udp): assert "udp" == config.protocol assert 1234 == config.udp_check_port - assert 16 == config.udp_session_timeout + assert 2 == config.udp_session_timeout def test_update_nb_config(test_linode_client, create_nb_config_with_udp): 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/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/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): 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/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. diff --git a/test/unit/objects/nodebalancers_test.py b/test/unit/objects/nodebalancers_test.py index ed0f0c320..c02b40ea3 100644 --- a/test/unit/objects/nodebalancers_test.py +++ b/test/unit/objects/nodebalancers_test.py @@ -175,6 +175,24 @@ def test_update(self): }, ) + def test_locks_not_in_put(self): + """ + Test that locks are not included in PUT request when updating a NodeBalancer. + Locks are managed through the separate /v4/locks endpoint. + """ + nb = NodeBalancer(self.client, 123456) + # Access locks to ensure it's loaded + self.assertEqual(nb.locks, ["cannot_delete_with_subresources"]) + + nb.label = "new-label" + + with self.mock_put("nodebalancers/123456") as m: + nb.save() + self.assertEqual(m.call_url, "/nodebalancers/123456") + # Verify locks is NOT in the PUT data + self.assertNotIn("locks", m.call_data) + self.assertEqual(m.call_data["label"], "new-label") + def test_firewalls(self): """ Test that you can get the firewalls for the requested NodeBalancer. 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) 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