diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 9b2113c6e..4d44d48d3 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -103,13 +103,13 @@ jobs: if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python tod_scripts/add_to_xml_test_report.py \ + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" sync - python3 tod_scripts/test_report_upload_script.py "${filename}" + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index abf5fb209..48cb55e13 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -81,13 +81,13 @@ jobs: if: always() run: | filename=$(ls | grep -E '^[0-9]{12}_sdk_test_report\.xml$') - python tod_scripts/add_to_xml_test_report.py \ + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/add_gha_info_to_xml.py \ --branch_name "${GITHUB_REF#refs/*/}" \ --gha_run_id "$GITHUB_RUN_ID" \ --gha_run_number "$GITHUB_RUN_NUMBER" \ --xmlfile "${filename}" sync - python3 tod_scripts/test_report_upload_script.py "${filename}" + python3 e2e_scripts/tod_scripts/xml_to_obj_storage/scripts/xml_to_obj.py "${filename}" env: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} diff --git a/.gitmodules b/.gitmodules index df7dc11d7..1a19a1c1a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "tod_scripts"] - path = tod_scripts - url = https://github.com/linode/TOD-test-report-uploader.git +[submodule "e2e_scripts"] + path = e2e_scripts + url = https://github.com/linode/dx-e2e-test-scripts diff --git a/e2e_scripts b/e2e_scripts new file mode 160000 index 000000000..b56178520 --- /dev/null +++ b/e2e_scripts @@ -0,0 +1 @@ +Subproject commit b56178520fae446a0a4f38df6259deb845efa667 diff --git a/linode_api4/groups/account.py b/linode_api4/groups/account.py index 88f53ed09..c2c69c624 100644 --- a/linode_api4/groups/account.py +++ b/linode_api4/groups/account.py @@ -504,7 +504,7 @@ def child_accounts(self, *filters): NOTE: Parent/Child related features may not be generally available. - API doc: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-child-accounts :returns: a list of all child accounts. :rtype: PaginatedList of ChildAccount diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index d22363af3..451a73d19 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -1,10 +1,10 @@ -from typing import BinaryIO, Tuple +from typing import BinaryIO, List, Optional, Tuple, Union import requests from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Image +from linode_api4.objects import Base, Disk, Image from linode_api4.util import drop_null_keys @@ -29,14 +29,21 @@ def __call__(self, *filters): """ return self.client._get_and_filter(Image, *filters) - def create(self, disk, label=None, description=None, cloud_init=False): + def create( + self, + disk: Union[Disk, int], + label: str = None, + description: str = None, + cloud_init: bool = False, + tags: Optional[List[str]] = None, + ): """ Creates a new Image from a disk you own. API Documentation: https://techdocs.akamai.com/linode-api/reference/post-image :param disk: The Disk to imagize. - :type disk: Disk or int + :type disk: Union[Disk, int] :param label: The label for the resulting Image (defaults to the disk's label. :type label: str @@ -44,24 +51,23 @@ def create(self, disk, label=None, description=None, cloud_init=False): :type description: str :param cloud_init: Whether this Image supports cloud-init. :type cloud_init: bool + :param tags: A list of customized tags of this new Image. + :type tags: Optional[List[str]] :returns: The new Image. :rtype: Image """ params = { "disk_id": disk.id if issubclass(type(disk), Base) else disk, + "label": label, + "description": description, + "tags": tags, } - if label is not None: - params["label"] = label - - if description is not None: - params["description"] = description - if cloud_init: params["cloud_init"] = cloud_init - result = self.client.post("/images", data=params) + result = self.client.post("/images", data=drop_null_keys(params)) if not "id" in result: raise UnexpectedResponseError( @@ -78,6 +84,7 @@ def create_upload( region: str, description: str = None, cloud_init: bool = False, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ Creates a new Image and returns the corresponding upload URL. @@ -92,11 +99,18 @@ def create_upload( :type description: str :param cloud_init: Whether this Image supports cloud-init. :type cloud_init: bool + :param tags: A list of customized tags of this Image. + :type tags: Optional[List[str]] :returns: A tuple containing the new image and the image upload URL. :rtype: (Image, str) """ - params = {"label": label, "region": region, "description": description} + params = { + "label": label, + "region": region, + "description": description, + "tags": tags, + } if cloud_init: params["cloud_init"] = cloud_init @@ -114,7 +128,12 @@ def create_upload( return Image(self.client, result_image["id"], result_image), result_url def upload( - self, label: str, region: str, file: BinaryIO, description: str = None + self, + label: str, + region: str, + file: BinaryIO, + description: str = None, + tags: Optional[List[str]] = None, ) -> Image: """ Creates and uploads a new image. @@ -128,12 +147,16 @@ def upload( :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb"). :param description: The description for the new Image. :type description: str + :param tags: A list of customized tags of this Image. + :type tags: Optional[List[str]] :returns: The resulting image. :rtype: Image """ - image, url = self.create_upload(label, region, description=description) + image, url = self.create_upload( + label, region, description=description, tags=tags + ) requests.put( url, diff --git a/linode_api4/groups/placement.py b/linode_api4/groups/placement.py index e56970346..b1fa0f32b 100644 --- a/linode_api4/groups/placement.py +++ b/linode_api4/groups/placement.py @@ -20,7 +20,7 @@ def groups(self, *filters): groups = client.placement.groups(PlacementGroup.label == "test") - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-placement-groups :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/vpc.py b/linode_api4/groups/vpc.py index f3f4f27b6..fa8066cea 100644 --- a/linode_api4/groups/vpc.py +++ b/linode_api4/groups/vpc.py @@ -16,7 +16,7 @@ def __call__(self, *filters) -> PaginatedList: vpcs = client.vpcs() - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpcs :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` @@ -38,7 +38,7 @@ def create( """ Creates a new VPC under your Linode account. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-vpc :param label: The label of the newly created VPC. :type label: str @@ -90,7 +90,7 @@ def ips(self, *filters) -> PaginatedList: vpc_ips = client.vpcs.ips() - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpcs-ips :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 8c7819119..66e3d45fe 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -3,7 +3,7 @@ import json import logging from importlib.metadata import version -from typing import BinaryIO, Tuple +from typing import BinaryIO, List, Tuple from urllib import parse import requests @@ -378,15 +378,21 @@ def __setattr__(self, key, value): super().__setattr__(key, value) - def image_create(self, disk, label=None, description=None): + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. Please use :meth:`LinodeClient.images.create(...) <.ImageGroup.create>` for all new projects. """ - return self.images.create(disk, label=label, description=description) + return self.images.create( + disk, label=label, description=description, tags=tags + ) def image_create_upload( - self, label: str, region: str, description: str = None + self, + label: str, + region: str, + description: str = None, + tags: List[str] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -394,16 +400,25 @@ def image_create_upload( for all new projects. """ - return self.images.create_upload(label, region, description=description) + return self.images.create_upload( + label, region, description=description, tags=tags + ) def image_upload( - self, label: str, region: str, file: BinaryIO, description: str = None + self, + label: str, + region: str, + file: BinaryIO, + description: str = None, + tags: List[str] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. Please use :meth:`LinodeClient.images.upload(...) <.ImageGroup.upload>` for all new projects. """ - return self.images.upload(label, region, file, description=description) + return self.images.upload( + label, region, file, description=description, tags=tags + ) def nodebalancer_create(self, region, **kwargs): """ diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 31e3cf33d..9365a9127 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -62,7 +62,7 @@ class ChildAccount(Account): NOTE: Parent/Child related features may not be generally available. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-child-account """ api_endpoint = "/account/child-accounts/{euuid}" @@ -70,9 +70,9 @@ class ChildAccount(Account): def create_token(self, **kwargs): """ - Create a ephemeral token for accessing the child account. + Create an ephemeral token for accessing the child account. - API Documentation: TBD + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-child-account-token """ resp = self._client.post( "{}/token".format(self.api_endpoint), diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 2317dd20d..b2c413f86 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -1,4 +1,31 @@ -from linode_api4.objects import Base, Property +from dataclasses import dataclass +from typing import List, Union + +from linode_api4.objects import Base, Property, Region +from linode_api4.objects.serializable import JSONObject, StrEnum + + +class ReplicationStatus(StrEnum): + """ + The Enum class represents image replication status. + """ + + pending_replication = "pending replication" + pending_deletion = "pending deletion" + available = "available" + creating = "creating" + pending = "pending" + replicating = "replicating" + + +@dataclass +class ImageRegion(JSONObject): + """ + The region and status of an image replica. + """ + + region: str = "" + status: ReplicationStatus = None class Image(Base): @@ -28,4 +55,35 @@ class Image(Base): "capabilities": Property( unordered=True, ), + "tags": Property(mutable=True, unordered=True), + "total_size": Property(), + "regions": Property(json_object=ImageRegion, unordered=True), } + + def replicate(self, regions: Union[List[str], List[Region]]): + """ + Replicate the image to other regions. + + Note: Image replication may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-replicate-image + + :param regions: A list of regions that the customer wants to replicate this image in. + At least one valid region is required and only core regions allowed. + Existing images in the regions not passed will be removed. + :type regions: List[str] + """ + params = { + "regions": [ + region.id if isinstance(region, Region) else region + for region in regions + ] + } + + result = self._client.post( + "{}/regions".format(self.api_endpoint), model=self, data=params + ) + + # The replicate endpoint returns the updated Image, so we can use this + # as an opportunity to refresh the object + self._populate(result) diff --git a/linode_api4/objects/linode.py b/linode_api4/objects/linode.py index 0e43f1567..39564200f 100644 --- a/linode_api4/objects/linode.py +++ b/linode_api4/objects/linode.py @@ -294,7 +294,7 @@ class NetworkInterface(DerivedBase): NOTE: This class cannot be used for the `interfaces` attribute on Config POST and PUT requests. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config-interface """ api_endpoint = ( @@ -369,7 +369,7 @@ class ConfigInterface(JSONObject): If you would like to access a config interface directly, consider using :any:`NetworkInterface`. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config-interface """ purpose: str = "public" @@ -462,7 +462,7 @@ def network_interfaces(self): This differs from the `interfaces` field as each NetworkInterface object is treated as its own API object. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-linode-config-interfaces """ return [ @@ -523,7 +523,7 @@ def interface_create_public(self, primary=False) -> NetworkInterface: """ Creates a public interface for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interface :param primary: Whether this interface is a primary interface. :type primary: bool @@ -540,7 +540,7 @@ def interface_create_vlan( """ Creates a VLAN interface for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interface :param label: The label of the VLAN to associate this interface with. :type label: str @@ -569,7 +569,7 @@ def interface_create_vpc( """ Creates a VPC interface for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interface :param subnet: The VPC subnet to associate this interface with. :type subnet: int or VPCSubnet @@ -605,7 +605,7 @@ def interface_reorder(self, interfaces: List[Union[int, NetworkInterface]]): """ Change the order of the interfaces for this Configuration Profile. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-linode-config-interfaces :param interfaces: A list of interfaces in the desired order. :type interfaces: List of str or NetworkInterface diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 14de05f45..d5e2c9d79 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -146,14 +146,29 @@ def _populate(self, json): Parse Nodes into more useful LKENodePoolNode objects """ if json is not None and json != {}: - new_nodes = [ - ( - LKENodePoolNode(self._client, c) - if not isinstance(c, dict) - else c - ) - for c in json["nodes"] - ] + new_nodes = [] + for c in json["nodes"]: + if isinstance(c, LKENodePoolNode): + new_nodes.append(c) + elif isinstance(c, dict): + node_id = c.get("id") + if node_id is not None: + new_nodes.append(LKENodePoolNode(self._client, c)) + else: + raise ValueError( + "Node dictionary does not contain 'id' key" + ) + elif isinstance(c, str): + node_details = self._client.get( + LKENodePool.api_endpoint.format( + cluster_id=self.id, id=c + ) + ) + new_nodes.append( + LKENodePoolNode(self._client, node_details) + ) + else: + raise TypeError("Unsupported node type: {}".format(type(c))) json["nodes"] = new_nodes super()._populate(json) diff --git a/linode_api4/objects/placement.py b/linode_api4/objects/placement.py index 616c9061f..aa894af33 100644 --- a/linode_api4/objects/placement.py +++ b/linode_api4/objects/placement.py @@ -41,7 +41,7 @@ class PlacementGroup(Base): A VM Placement Group, defining the affinity policy for Linodes created in a region. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-placement-group """ api_endpoint = "/placement/groups/{id}" @@ -66,8 +66,6 @@ def assign( :param linodes: A list of Linodes to assign to the Placement Group. :type linodes: List[Union[Instance, int]] - :param compliant_only: TODO - :type compliant_only: bool """ params = { "linodes": [ diff --git a/linode_api4/objects/vpc.py b/linode_api4/objects/vpc.py index e44eebcdc..456bdcfbc 100644 --- a/linode_api4/objects/vpc.py +++ b/linode_api4/objects/vpc.py @@ -23,7 +23,7 @@ class VPCSubnet(DerivedBase): """ An instance of a VPC subnet. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpc-subnet """ api_endpoint = "/vpcs/{vpc_id}/subnets/{id}" @@ -44,7 +44,7 @@ class VPC(Base): """ An instance of a VPC. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpc """ api_endpoint = "/vpcs/{id}" @@ -68,7 +68,7 @@ def subnet_create( """ Creates a new Subnet object under this VPC. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-vpc-subnet :param label: The label of this subnet. :type label: str @@ -104,7 +104,7 @@ def ips(self) -> PaginatedList: """ Get all the IP addresses under this VPC. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-vpc-ips :returns: A list of VPCIPAddresses the acting user can access. :rtype: PaginatedList of VPCIPAddress diff --git a/linode_api4/polling.py b/linode_api4/polling.py index 6ba02a5b1..947e59e47 100644 --- a/linode_api4/polling.py +++ b/linode_api4/polling.py @@ -13,7 +13,7 @@ class EventError(Exception): def __init__(self, event_id: int, message: Optional[str]): # Edge case, sometimes the message is populated with an empty string - if len(message) < 1: + if message is not None and len(message) < 1: message = None self.event_id = event_id diff --git a/test/fixtures/images.json b/test/fixtures/images.json index c33141527..357110bc7 100644 --- a/test/fixtures/images.json +++ b/test/fixtures/images.json @@ -18,7 +18,15 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "tags": ["tests"], + "total_size": 1100, + "regions": [ + { + "region": "us-east", + "status": "available" + } + ] }, { "created": "2017-01-01T00:01:01", @@ -35,7 +43,19 @@ "eol": "2026-07-01T04:00:00", "expiry": "2026-08-01T04:00:00", "updated": "2020-07-01T04:00:00", - "capabilities": [] + "capabilities": [], + "tags": ["tests"], + "total_size": 3000, + "regions": [ + { + "region": "us-east", + "status": "available" + }, + { + "region": "us-mia", + "status": "pending" + } + ] }, { "created": "2017-01-01T00:01:01", diff --git a/test/fixtures/images_private_123_regions.json b/test/fixtures/images_private_123_regions.json new file mode 100644 index 000000000..5540fc116 --- /dev/null +++ b/test/fixtures/images_private_123_regions.json @@ -0,0 +1,29 @@ +{ + "created": "2017-08-20T14:01:01", + "description": null, + "deprecated": false, + "status": "available", + "created_by": "testguy", + "id": "private/123", + "label": "Gold Master", + "size": 650, + "is_public": false, + "type": "manual", + "vendor": null, + "eol": "2026-07-01T04:00:00", + "expiry": "2026-08-01T04:00:00", + "updated": "2020-07-01T04:00:00", + "capabilities": ["cloud-init"], + "tags": ["tests"], + "total_size": 1300, + "regions": [ + { + "region": "us-east", + "status": "available" + }, + { + "region": "us-west", + "status": "pending replication" + } + ] +} \ No newline at end of file diff --git a/test/fixtures/images_upload.json b/test/fixtures/images_upload.json index 60f726464..893270130 100644 --- a/test/fixtures/images_upload.json +++ b/test/fixtures/images_upload.json @@ -14,7 +14,8 @@ "type": "manual", "updated": "2021-08-14T22:44:02", "vendor": "Debian", - "capabilities": ["cloud-init"] + "capabilities": ["cloud-init"], + "tags": ["test_tag", "test2"] }, "upload_to": "https://linode.com/" } \ No newline at end of file diff --git a/test/integration/conftest.py b/test/integration/conftest.py index e50ac3abc..220cd4093 100644 --- a/test/integration/conftest.py +++ b/test/integration/conftest.py @@ -34,7 +34,9 @@ def get_random_label(): return label -def get_region(client: LinodeClient, capabilities: Set[str] = None): +def get_region( + client: LinodeClient, capabilities: Set[str] = None, site_type: str = None +): region_override = os.environ.get(ENV_REGION_OVERRIDE) # Allow overriding the target test region @@ -48,6 +50,9 @@ def get_region(client: LinodeClient, capabilities: Set[str] = None): v for v in regions if set(capabilities).issubset(v.capabilities) ] + if site_type is not None: + regions = [v for v in regions if v.site_type == site_type] + return random.choice(regions) diff --git a/test/integration/linode_client/test_linode_client.py b/test/integration/linode_client/test_linode_client.py index df634cf06..92224abd4 100644 --- a/test/integration/linode_client/test_linode_client.py +++ b/test/integration/linode_client/test_linode_client.py @@ -1,5 +1,6 @@ import re import time +from test.integration.conftest import get_region from test.integration.helpers import get_test_label import pytest @@ -11,8 +12,10 @@ @pytest.fixture(scope="session") def setup_client_and_linode(test_linode_client, e2e_test_firewall): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] # us-ord (Chicago) + chosen_region = get_region( + client, {"Kubernetes", "NodeBalancers"}, "core" + ).id + label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -90,14 +93,18 @@ def test_image_create(setup_client_and_linode): label = get_test_label() description = "Test description" + tags = ["test"] usable_disk = [v for v in linode.disks if v.filesystem != "swap"] image = client.image_create( - disk=usable_disk[0].id, label=label, description=description + disk=usable_disk[0].id, label=label, description=description, tags=tags ) assert image.label == label assert image.description == description + assert image.tags == tags + # size and total_size are the same because this image is not replicated + assert image.size == image.total_size def test_fails_to_create_image_with_non_existing_disk_id( @@ -215,7 +222,7 @@ def test_get_account_settings(test_linode_client): assert account_settings._populated == True assert re.search( - "'network_helper':True|False", str(account_settings._raw_json) + "'network_helper':\s*(True|False)", str(account_settings._raw_json) ) @@ -225,8 +232,7 @@ def test_get_account_settings(test_linode_client): # LinodeGroupTests def test_create_linode_instance_without_image(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Linodes"}, "core").id label = get_test_label() linode_instance = client.linode.instance_create( @@ -250,8 +256,7 @@ def test_create_linode_instance_with_image(setup_client_and_linode): def test_create_linode_with_interfaces(test_linode_client): client = test_linode_client - available_regions = client.regions() - chosen_region = available_regions[4] + chosen_region = get_region(client, {"Vlans", "Linodes"}).id label = get_test_label() linode_instance, password = client.linode.instance_create( @@ -323,7 +328,7 @@ def test_cluster_create_with_api_objects(test_linode_client): client = test_linode_client node_type = client.linode.types()[1] # g6-standard-1 version = client.lke.versions()[0] - region = client.regions().first() + region = get_region(client, {"Kubernetes"}) node_pools = client.lke.node_pool(node_type, 3) label = get_test_label() @@ -340,10 +345,11 @@ def test_cluster_create_with_api_objects(test_linode_client): def test_fails_to_create_cluster_with_invalid_version(test_linode_client): invalid_version = "a.12" client = test_linode_client + region = get_region(client, {"Kubernetes"}).id try: cluster = client.lke.cluster_create( - "us-ord", + region, "example-cluster", {"type": "g6-standard-1", "count": 3}, invalid_version, diff --git a/test/integration/models/image/test_image.py b/test/integration/models/image/test_image.py index a622b355e..5c4025dfc 100644 --- a/test/integration/models/image/test_image.py +++ b/test/integration/models/image/test_image.py @@ -1,20 +1,24 @@ from io import BytesIO +from test.integration.conftest import get_region from test.integration.helpers import ( delete_instance_with_test_kw, get_test_label, ) +import polling import pytest from linode_api4.objects import Image @pytest.fixture(scope="session") -def image_upload(test_linode_client): +def image_upload_url(test_linode_client): label = get_test_label() + "_image" + region = get_region(test_linode_client, site_type="core") + test_linode_client.image_create_upload( - label, "us-east", "integration test image upload" + label, region.id, "integration test image upload" ) image = test_linode_client.images()[0] @@ -26,26 +30,63 @@ def image_upload(test_linode_client): delete_instance_with_test_kw(images) -@pytest.mark.smoke -def test_get_image(test_linode_client, image_upload): - image = test_linode_client.load(Image, image_upload.id) - - assert image.label == image_upload.label - - -def test_image_create_upload(test_linode_client): +@pytest.fixture(scope="session") +def test_uploaded_image(test_linode_client): test_image_content = ( b"\x1F\x8B\x08\x08\xBD\x5C\x91\x60\x00\x03\x74\x65\x73\x74\x2E\x69" b"\x6D\x67\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00" ) label = get_test_label() + "_image" + image = test_linode_client.image_upload( label, - "us-ord", + "us-east", BytesIO(test_image_content), description="integration test image upload", + tags=["tests"], ) - assert image.label == label + yield image + + image.delete() + + +@pytest.mark.smoke +def test_get_image(test_linode_client, image_upload_url): + image = test_linode_client.load(Image, image_upload_url.id) + + assert image.label == image_upload_url.label + + +def test_image_create_upload(test_linode_client, test_uploaded_image): + image = test_linode_client.load(Image, test_uploaded_image.id) + + assert image.label == test_uploaded_image.label assert image.description == "integration test image upload" + assert image.tags[0] == "tests" + + +@pytest.mark.smoke +def test_image_replication(test_linode_client, test_uploaded_image): + image = test_linode_client.load(Image, test_uploaded_image.id) + + # wait for image to be available for replication + def poll_func() -> bool: + image._api_get() + return image.status in {"available"} + + try: + polling.poll( + poll_func, + step=10, + timeout=250, + ) + except polling.TimeoutException: + print("failed to wait for image status: timeout period expired.") + + # image replication works stably in these two regions + image.replicate(["us-east", "eu-west"]) + + assert image.label == test_uploaded_image.label + assert len(image.regions) == 2 diff --git a/test/unit/linode_client_test.py b/test/unit/linode_client_test.py index 081b27d09..84c003e97 100644 --- a/test/unit/linode_client_test.py +++ b/test/unit/linode_client_test.py @@ -127,7 +127,9 @@ def test_image_create(self): Tests that an Image can be created successfully """ with self.mock_post("images/private/123") as m: - i = self.client.image_create(654, "Test-Image", "This is a test") + i = self.client.image_create( + 654, "Test-Image", "This is a test", ["test"] + ) self.assertIsNotNone(i) self.assertEqual(i.id, "private/123") @@ -141,6 +143,7 @@ def test_image_create(self): "disk_id": 654, "label": "Test-Image", "description": "This is a test", + "tags": ["test"], }, ) diff --git a/test/unit/objects/image_test.py b/test/unit/objects/image_test.py index 983192e69..d4851e777 100644 --- a/test/unit/objects/image_test.py +++ b/test/unit/objects/image_test.py @@ -4,7 +4,7 @@ from typing import BinaryIO from unittest.mock import patch -from linode_api4.objects import Image +from linode_api4.objects import Image, Region # A minimal gzipped image that will be accepted by the API TEST_IMAGE_CONTENT = ( @@ -51,6 +51,11 @@ def test_get_image(self): datetime(year=2020, month=7, day=1, hour=4, minute=0, second=0), ) + self.assertEqual(image.tags[0], "tests") + self.assertEqual(image.total_size, 1100) + self.assertEqual(image.regions[0].region, "us-east") + self.assertEqual(image.regions[0].status, "available") + def test_image_create_upload(self): """ Test that an image upload URL can be created successfully. @@ -61,6 +66,7 @@ def test_image_create_upload(self): "Realest Image Upload", "us-southeast", description="very real image upload.", + tags=["test_tag", "test2"], ) self.assertEqual(m.call_url, "/images/upload") @@ -71,6 +77,7 @@ def test_image_create_upload(self): "label": "Realest Image Upload", "region": "us-southeast", "description": "very real image upload.", + "tags": ["test_tag", "test2"], }, ) @@ -78,6 +85,8 @@ def test_image_create_upload(self): self.assertEqual(image.label, "Realest Image Upload") self.assertEqual(image.description, "very real image upload.") self.assertEqual(image.capabilities[0], "cloud-init") + self.assertEqual(image.tags[0], "test_tag") + self.assertEqual(image.tags[1], "test2") self.assertEqual(url, "https://linode.com/") @@ -96,11 +105,14 @@ def put_mock(url: str, data: BinaryIO = None, **kwargs): "us-southeast", BytesIO(TEST_IMAGE_CONTENT), description="very real image upload.", + tags=["test_tag", "test2"], ) self.assertEqual(image.id, "private/1337") self.assertEqual(image.label, "Realest Image Upload") self.assertEqual(image.description, "very real image upload.") + self.assertEqual(image.tags[0], "test_tag") + self.assertEqual(image.tags[1], "test2") def test_image_create_cloud_init(self): """ @@ -131,3 +143,20 @@ def test_image_create_upload_cloud_init(self): ) self.assertTrue(m.call_data["cloud_init"]) + + def test_image_replication(self): + """ + Test that image can be replicated. + """ + + replication_url = "/images/private/123/regions" + regions = ["us-east", Region(self.client, "us-west")] + with self.mock_post(replication_url) as m: + image = Image(self.client, "private/123") + image.replicate(regions) + + self.assertEqual(replication_url, m.call_url) + self.assertEqual( + m.call_data, + {"regions": ["us-east", "us-west"]}, + ) diff --git a/test/unit/objects/lke_test.py b/test/unit/objects/lke_test.py index f39fb84ae..390aa0de2 100644 --- a/test/unit/objects/lke_test.py +++ b/test/unit/objects/lke_test.py @@ -1,5 +1,6 @@ from datetime import datetime from test.unit.base import ClientBaseCase +from unittest.mock import MagicMock from linode_api4 import InstanceDiskEncryptionType from linode_api4.objects import ( @@ -9,6 +10,7 @@ LKEClusterControlPlaneOptions, LKENodePool, ) +from linode_api4.objects.lke import LKENodePoolNode class LKETest(ClientBaseCase): @@ -262,3 +264,94 @@ def test_cluster_delete_acl(self): assert m.call_url == "/lke/clusters/18881/control_plane_acl" assert m.method == "get" + + def test_populate_with_node_objects(self): + """ + Tests that LKENodePool correctly handles a list of LKENodePoolNode objects. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node1 = LKENodePoolNode( + self.client, {"id": "node1", "instance_id": 101, "status": "active"} + ) + node2 = LKENodePoolNode( + self.client, + {"id": "node2", "instance_id": 102, "status": "inactive"}, + ) + self.pool._populate({"nodes": [node1, node2]}) + + self.assertEqual(len(self.pool.nodes), 2) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node1") + self.assertEqual(self.pool.nodes[1].id, "node2") + + def test_populate_with_node_dicts(self): + """ + Tests that LKENodePool correctly handles a list of node dictionaries. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node_dict1 = {"id": "node3", "instance_id": 103, "status": "pending"} + node_dict2 = {"id": "node4", "instance_id": 104, "status": "failed"} + self.pool._populate({"nodes": [node_dict1, node_dict2]}) + + self.assertEqual(len(self.pool.nodes), 2) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node3") + self.assertEqual(self.pool.nodes[1].id, "node4") + + def test_populate_with_node_ids(self): + """ + Tests that LKENodePool correctly handles a list of node IDs. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node_id1 = "node5" + node_id2 = "node6" + # Mock instances creation + self.client.get = MagicMock( + side_effect=[ + {"id": "node5", "instance_id": 105, "status": "active"}, + {"id": "node6", "instance_id": 106, "status": "inactive"}, + ] + ) + self.pool._populate({"nodes": [node_id1, node_id2]}) + + self.assertEqual(len(self.pool.nodes), 2) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node5") + self.assertEqual(self.pool.nodes[1].id, "node6") + + def test_populate_with_mixed_types(self): + """ + Tests that LKENodePool correctly handles a mixed list of node objects, dicts, and IDs. + """ + self.client = MagicMock() + self.pool = LKENodePool(self.client, 456, 18881) + + node1 = LKENodePoolNode( + self.client, {"id": "node7", "instance_id": 107, "status": "active"} + ) + node_dict = {"id": "node8", "instance_id": 108, "status": "inactive"} + node_id = "node9" + # Mock instances creation + self.client.get = MagicMock( + side_effect=[ + {"id": "node9", "instance_id": 109, "status": "pending"} + ] + ) + self.pool._populate({"nodes": [node1, node_dict, node_id]}) + + self.assertEqual(len(self.pool.nodes), 3) + self.assertIsInstance(self.pool.nodes[0], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[1], LKENodePoolNode) + self.assertIsInstance(self.pool.nodes[2], LKENodePoolNode) + self.assertEqual(self.pool.nodes[0].id, "node7") + self.assertEqual(self.pool.nodes[1].id, "node8") + self.assertEqual(self.pool.nodes[2].id, "node9") diff --git a/test/unit/objects/polling_test.py b/test/unit/objects/polling_test.py index 7fb7c684f..09c958882 100644 --- a/test/unit/objects/polling_test.py +++ b/test/unit/objects/polling_test.py @@ -328,3 +328,17 @@ def test_wait_for_event_finished_failed( assert err.message == "oh no!" else: raise Exception("Expected event error, got none") + + def test_event_error( + self, + ): + """ + Tests that EventError objects can be constructed and + will be formatted to the correct output. + + Tests for regression of TPT-3060 + """ + + assert str(EventError(123, None)) == "Event 123 failed" + assert str(EventError(123, "")) == "Event 123 failed" + assert str(EventError(123, "foobar")) == "Event 123 failed: foobar" diff --git a/tod_scripts b/tod_scripts deleted file mode 160000 index 41b85dd2c..000000000 --- a/tod_scripts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 41b85dd2c5588b5b343b8ee365b2f4f196cd9a7f