diff --git a/linode_api4/groups/image.py b/linode_api4/groups/image.py index d22363af..451a73d1 100644 --- a/linode_api4/groups/image.py +++ b/linode_api4/groups/image.py @@ -1,10 +1,10 @@ -from typing import BinaryIO, Tuple +from typing import BinaryIO, List, Optional, Tuple, Union import requests from linode_api4.errors import UnexpectedResponseError from linode_api4.groups import Group -from linode_api4.objects import Base, Image +from linode_api4.objects import Base, Disk, Image from linode_api4.util import drop_null_keys @@ -29,14 +29,21 @@ def __call__(self, *filters): """ return self.client._get_and_filter(Image, *filters) - def create(self, disk, label=None, description=None, cloud_init=False): + def create( + self, + disk: Union[Disk, int], + label: str = None, + description: str = None, + cloud_init: bool = False, + tags: Optional[List[str]] = None, + ): """ Creates a new Image from a disk you own. API Documentation: https://techdocs.akamai.com/linode-api/reference/post-image :param disk: The Disk to imagize. - :type disk: Disk or int + :type disk: Union[Disk, int] :param label: The label for the resulting Image (defaults to the disk's label. :type label: str @@ -44,24 +51,23 @@ def create(self, disk, label=None, description=None, cloud_init=False): :type description: str :param cloud_init: Whether this Image supports cloud-init. :type cloud_init: bool + :param tags: A list of customized tags of this new Image. + :type tags: Optional[List[str]] :returns: The new Image. :rtype: Image """ params = { "disk_id": disk.id if issubclass(type(disk), Base) else disk, + "label": label, + "description": description, + "tags": tags, } - if label is not None: - params["label"] = label - - if description is not None: - params["description"] = description - if cloud_init: params["cloud_init"] = cloud_init - result = self.client.post("/images", data=params) + result = self.client.post("/images", data=drop_null_keys(params)) if not "id" in result: raise UnexpectedResponseError( @@ -78,6 +84,7 @@ def create_upload( region: str, description: str = None, cloud_init: bool = False, + tags: Optional[List[str]] = None, ) -> Tuple[Image, str]: """ Creates a new Image and returns the corresponding upload URL. @@ -92,11 +99,18 @@ def create_upload( :type description: str :param cloud_init: Whether this Image supports cloud-init. :type cloud_init: bool + :param tags: A list of customized tags of this Image. + :type tags: Optional[List[str]] :returns: A tuple containing the new image and the image upload URL. :rtype: (Image, str) """ - params = {"label": label, "region": region, "description": description} + params = { + "label": label, + "region": region, + "description": description, + "tags": tags, + } if cloud_init: params["cloud_init"] = cloud_init @@ -114,7 +128,12 @@ def create_upload( return Image(self.client, result_image["id"], result_image), result_url def upload( - self, label: str, region: str, file: BinaryIO, description: str = None + self, + label: str, + region: str, + file: BinaryIO, + description: str = None, + tags: Optional[List[str]] = None, ) -> Image: """ Creates and uploads a new image. @@ -128,12 +147,16 @@ def upload( :param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb"). :param description: The description for the new Image. :type description: str + :param tags: A list of customized tags of this Image. + :type tags: Optional[List[str]] :returns: The resulting image. :rtype: Image """ - image, url = self.create_upload(label, region, description=description) + image, url = self.create_upload( + label, region, description=description, tags=tags + ) requests.put( url, diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 8c781911..66e3d45f 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -3,7 +3,7 @@ import json import logging from importlib.metadata import version -from typing import BinaryIO, Tuple +from typing import BinaryIO, List, Tuple from urllib import parse import requests @@ -378,15 +378,21 @@ def __setattr__(self, key, value): super().__setattr__(key, value) - def image_create(self, disk, label=None, description=None): + def image_create(self, disk, label=None, description=None, tags=None): """ .. note:: This method is an alias to maintain backwards compatibility. Please use :meth:`LinodeClient.images.create(...) <.ImageGroup.create>` for all new projects. """ - return self.images.create(disk, label=label, description=description) + return self.images.create( + disk, label=label, description=description, tags=tags + ) def image_create_upload( - self, label: str, region: str, description: str = None + self, + label: str, + region: str, + description: str = None, + tags: List[str] = None, ) -> Tuple[Image, str]: """ .. note:: This method is an alias to maintain backwards compatibility. @@ -394,16 +400,25 @@ def image_create_upload( for all new projects. """ - return self.images.create_upload(label, region, description=description) + return self.images.create_upload( + label, region, description=description, tags=tags + ) def image_upload( - self, label: str, region: str, file: BinaryIO, description: str = None + self, + label: str, + region: str, + file: BinaryIO, + description: str = None, + tags: List[str] = None, ) -> Image: """ .. note:: This method is an alias to maintain backwards compatibility. Please use :meth:`LinodeClient.images.upload(...) <.ImageGroup.upload>` for all new projects. """ - return self.images.upload(label, region, file, description=description) + return self.images.upload( + label, region, file, description=description, tags=tags + ) def nodebalancer_create(self, region, **kwargs): """ diff --git a/linode_api4/objects/image.py b/linode_api4/objects/image.py index 2317dd20..b2c413f8 100644 --- a/linode_api4/objects/image.py +++ b/linode_api4/objects/image.py @@ -1,4 +1,31 @@ -from linode_api4.objects import Base, Property +from dataclasses import dataclass +from typing import List, Union + +from linode_api4.objects import Base, Property, Region +from linode_api4.objects.serializable import JSONObject, StrEnum + + +class ReplicationStatus(StrEnum): + """ + The Enum class represents image replication status. + """ + + pending_replication = "pending replication" + pending_deletion = "pending deletion" + available = "available" + creating = "creating" + pending = "pending" + replicating = "replicating" + + +@dataclass +class ImageRegion(JSONObject): + """ + The region and status of an image replica. + """ + + region: str = "" + status: ReplicationStatus = None class Image(Base): @@ -28,4 +55,35 @@ class Image(Base): "capabilities": Property( unordered=True, ), + "tags": Property(mutable=True, unordered=True), + "total_size": Property(), + "regions": Property(json_object=ImageRegion, unordered=True), } + + def replicate(self, regions: Union[List[str], List[Region]]): + """ + Replicate the image to other regions. + + Note: Image replication may not currently be available to all users. + + API Documentation: https://techdocs.akamai.com/linode-api/reference/post-replicate-image + + :param regions: A list of regions that the customer wants to replicate this image in. + At least one valid region is required and only core regions allowed. + Existing images in the regions not passed will be removed. + :type regions: List[str] + """ + params = { + "regions": [ + region.id if isinstance(region, Region) else region + for region in regions + ] + } + + result = self._client.post( + "{}/regions".format(self.api_endpoint), model=self, data=params + ) + + # The replicate endpoint returns the updated Image, so we can use this + # as an opportunity to refresh the object + self._populate(result) diff --git a/test/fixtures/images.json b/test/fixtures/images.json index c3314152..357110bc 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 00000000..5540fc11 --- /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 60f72646..89327013 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 e50ac3ab..220cd409 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 df634cf0..92224abd 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 a622b355..5c4025df 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 081b27d0..84c003e9 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 983192e6..d4851e77 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"]}, + )