Skip to content

Commit

Permalink
Project: Image Services Gen2 (#445)
Browse files Browse the repository at this point in the history
* new: Support Image Gen2 functionalities (#428)

* image gen2

* nit

* lint

* fix strenum import

* sort imports

* add int test

* address comments

* rename

* added LA disclamier; modified replication test case

* use random region in test_linode_client with caps; use stable regions for image gen2

* fix int test

* fix lint

* replace todo with doc link
  • Loading branch information
yec-akamai authored Aug 9, 2024
1 parent ebf5cdd commit cea7eb2
Show file tree
Hide file tree
Showing 11 changed files with 280 additions and 50 deletions.
51 changes: 37 additions & 14 deletions linode_api4/groups/image.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -29,39 +29,45 @@ 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
:param description: The description for the new Image.
: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(
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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,
Expand Down
29 changes: 22 additions & 7 deletions linode_api4/linode_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -378,32 +378,47 @@ 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.
Please use :meth:`LinodeClient.images.create_upload(...) <.ImageGroup.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):
"""
Expand Down
60 changes: 59 additions & 1 deletion linode_api4/objects/image.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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)
24 changes: 22 additions & 2 deletions test/fixtures/images.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions test/fixtures/images_private_123_regions.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
3 changes: 2 additions & 1 deletion test/fixtures/images_upload.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
}
7 changes: 6 additions & 1 deletion test/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)


Expand Down
Loading

0 comments on commit cea7eb2

Please sign in to comment.