Skip to content

Commit cea7eb2

Browse files
authored
Project: Image Services Gen2 (#445)
* 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
1 parent ebf5cdd commit cea7eb2

File tree

11 files changed

+280
-50
lines changed

11 files changed

+280
-50
lines changed

linode_api4/groups/image.py

+37-14
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
from typing import BinaryIO, Tuple
1+
from typing import BinaryIO, List, Optional, Tuple, Union
22

33
import requests
44

55
from linode_api4.errors import UnexpectedResponseError
66
from linode_api4.groups import Group
7-
from linode_api4.objects import Base, Image
7+
from linode_api4.objects import Base, Disk, Image
88
from linode_api4.util import drop_null_keys
99

1010

@@ -29,39 +29,45 @@ def __call__(self, *filters):
2929
"""
3030
return self.client._get_and_filter(Image, *filters)
3131

32-
def create(self, disk, label=None, description=None, cloud_init=False):
32+
def create(
33+
self,
34+
disk: Union[Disk, int],
35+
label: str = None,
36+
description: str = None,
37+
cloud_init: bool = False,
38+
tags: Optional[List[str]] = None,
39+
):
3340
"""
3441
Creates a new Image from a disk you own.
3542
3643
API Documentation: https://techdocs.akamai.com/linode-api/reference/post-image
3744
3845
:param disk: The Disk to imagize.
39-
:type disk: Disk or int
46+
:type disk: Union[Disk, int]
4047
:param label: The label for the resulting Image (defaults to the disk's
4148
label.
4249
:type label: str
4350
:param description: The description for the new Image.
4451
:type description: str
4552
:param cloud_init: Whether this Image supports cloud-init.
4653
:type cloud_init: bool
54+
:param tags: A list of customized tags of this new Image.
55+
:type tags: Optional[List[str]]
4756
4857
:returns: The new Image.
4958
:rtype: Image
5059
"""
5160
params = {
5261
"disk_id": disk.id if issubclass(type(disk), Base) else disk,
62+
"label": label,
63+
"description": description,
64+
"tags": tags,
5365
}
5466

55-
if label is not None:
56-
params["label"] = label
57-
58-
if description is not None:
59-
params["description"] = description
60-
6167
if cloud_init:
6268
params["cloud_init"] = cloud_init
6369

64-
result = self.client.post("/images", data=params)
70+
result = self.client.post("/images", data=drop_null_keys(params))
6571

6672
if not "id" in result:
6773
raise UnexpectedResponseError(
@@ -78,6 +84,7 @@ def create_upload(
7884
region: str,
7985
description: str = None,
8086
cloud_init: bool = False,
87+
tags: Optional[List[str]] = None,
8188
) -> Tuple[Image, str]:
8289
"""
8390
Creates a new Image and returns the corresponding upload URL.
@@ -92,11 +99,18 @@ def create_upload(
9299
:type description: str
93100
:param cloud_init: Whether this Image supports cloud-init.
94101
:type cloud_init: bool
102+
:param tags: A list of customized tags of this Image.
103+
:type tags: Optional[List[str]]
95104
96105
:returns: A tuple containing the new image and the image upload URL.
97106
:rtype: (Image, str)
98107
"""
99-
params = {"label": label, "region": region, "description": description}
108+
params = {
109+
"label": label,
110+
"region": region,
111+
"description": description,
112+
"tags": tags,
113+
}
100114

101115
if cloud_init:
102116
params["cloud_init"] = cloud_init
@@ -114,7 +128,12 @@ def create_upload(
114128
return Image(self.client, result_image["id"], result_image), result_url
115129

116130
def upload(
117-
self, label: str, region: str, file: BinaryIO, description: str = None
131+
self,
132+
label: str,
133+
region: str,
134+
file: BinaryIO,
135+
description: str = None,
136+
tags: Optional[List[str]] = None,
118137
) -> Image:
119138
"""
120139
Creates and uploads a new image.
@@ -128,12 +147,16 @@ def upload(
128147
:param file: The BinaryIO object to upload to the image. This is generally obtained from open("myfile", "rb").
129148
:param description: The description for the new Image.
130149
:type description: str
150+
:param tags: A list of customized tags of this Image.
151+
:type tags: Optional[List[str]]
131152
132153
:returns: The resulting image.
133154
:rtype: Image
134155
"""
135156

136-
image, url = self.create_upload(label, region, description=description)
157+
image, url = self.create_upload(
158+
label, region, description=description, tags=tags
159+
)
137160

138161
requests.put(
139162
url,

linode_api4/linode_client.py

+22-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44
import logging
55
from importlib.metadata import version
6-
from typing import BinaryIO, Tuple
6+
from typing import BinaryIO, List, Tuple
77
from urllib import parse
88

99
import requests
@@ -378,32 +378,47 @@ def __setattr__(self, key, value):
378378

379379
super().__setattr__(key, value)
380380

381-
def image_create(self, disk, label=None, description=None):
381+
def image_create(self, disk, label=None, description=None, tags=None):
382382
"""
383383
.. note:: This method is an alias to maintain backwards compatibility.
384384
Please use :meth:`LinodeClient.images.create(...) <.ImageGroup.create>` for all new projects.
385385
"""
386-
return self.images.create(disk, label=label, description=description)
386+
return self.images.create(
387+
disk, label=label, description=description, tags=tags
388+
)
387389

388390
def image_create_upload(
389-
self, label: str, region: str, description: str = None
391+
self,
392+
label: str,
393+
region: str,
394+
description: str = None,
395+
tags: List[str] = None,
390396
) -> Tuple[Image, str]:
391397
"""
392398
.. note:: This method is an alias to maintain backwards compatibility.
393399
Please use :meth:`LinodeClient.images.create_upload(...) <.ImageGroup.create_upload>`
394400
for all new projects.
395401
"""
396402

397-
return self.images.create_upload(label, region, description=description)
403+
return self.images.create_upload(
404+
label, region, description=description, tags=tags
405+
)
398406

399407
def image_upload(
400-
self, label: str, region: str, file: BinaryIO, description: str = None
408+
self,
409+
label: str,
410+
region: str,
411+
file: BinaryIO,
412+
description: str = None,
413+
tags: List[str] = None,
401414
) -> Image:
402415
"""
403416
.. note:: This method is an alias to maintain backwards compatibility.
404417
Please use :meth:`LinodeClient.images.upload(...) <.ImageGroup.upload>` for all new projects.
405418
"""
406-
return self.images.upload(label, region, file, description=description)
419+
return self.images.upload(
420+
label, region, file, description=description, tags=tags
421+
)
407422

408423
def nodebalancer_create(self, region, **kwargs):
409424
"""

linode_api4/objects/image.py

+59-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
1-
from linode_api4.objects import Base, Property
1+
from dataclasses import dataclass
2+
from typing import List, Union
3+
4+
from linode_api4.objects import Base, Property, Region
5+
from linode_api4.objects.serializable import JSONObject, StrEnum
6+
7+
8+
class ReplicationStatus(StrEnum):
9+
"""
10+
The Enum class represents image replication status.
11+
"""
12+
13+
pending_replication = "pending replication"
14+
pending_deletion = "pending deletion"
15+
available = "available"
16+
creating = "creating"
17+
pending = "pending"
18+
replicating = "replicating"
19+
20+
21+
@dataclass
22+
class ImageRegion(JSONObject):
23+
"""
24+
The region and status of an image replica.
25+
"""
26+
27+
region: str = ""
28+
status: ReplicationStatus = None
229

330

431
class Image(Base):
@@ -28,4 +55,35 @@ class Image(Base):
2855
"capabilities": Property(
2956
unordered=True,
3057
),
58+
"tags": Property(mutable=True, unordered=True),
59+
"total_size": Property(),
60+
"regions": Property(json_object=ImageRegion, unordered=True),
3161
}
62+
63+
def replicate(self, regions: Union[List[str], List[Region]]):
64+
"""
65+
Replicate the image to other regions.
66+
67+
Note: Image replication may not currently be available to all users.
68+
69+
API Documentation: https://techdocs.akamai.com/linode-api/reference/post-replicate-image
70+
71+
:param regions: A list of regions that the customer wants to replicate this image in.
72+
At least one valid region is required and only core regions allowed.
73+
Existing images in the regions not passed will be removed.
74+
:type regions: List[str]
75+
"""
76+
params = {
77+
"regions": [
78+
region.id if isinstance(region, Region) else region
79+
for region in regions
80+
]
81+
}
82+
83+
result = self._client.post(
84+
"{}/regions".format(self.api_endpoint), model=self, data=params
85+
)
86+
87+
# The replicate endpoint returns the updated Image, so we can use this
88+
# as an opportunity to refresh the object
89+
self._populate(result)

test/fixtures/images.json

+22-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@
1818
"eol": "2026-07-01T04:00:00",
1919
"expiry": "2026-08-01T04:00:00",
2020
"updated": "2020-07-01T04:00:00",
21-
"capabilities": []
21+
"capabilities": [],
22+
"tags": ["tests"],
23+
"total_size": 1100,
24+
"regions": [
25+
{
26+
"region": "us-east",
27+
"status": "available"
28+
}
29+
]
2230
},
2331
{
2432
"created": "2017-01-01T00:01:01",
@@ -35,7 +43,19 @@
3543
"eol": "2026-07-01T04:00:00",
3644
"expiry": "2026-08-01T04:00:00",
3745
"updated": "2020-07-01T04:00:00",
38-
"capabilities": []
46+
"capabilities": [],
47+
"tags": ["tests"],
48+
"total_size": 3000,
49+
"regions": [
50+
{
51+
"region": "us-east",
52+
"status": "available"
53+
},
54+
{
55+
"region": "us-mia",
56+
"status": "pending"
57+
}
58+
]
3959
},
4060
{
4161
"created": "2017-01-01T00:01:01",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"created": "2017-08-20T14:01:01",
3+
"description": null,
4+
"deprecated": false,
5+
"status": "available",
6+
"created_by": "testguy",
7+
"id": "private/123",
8+
"label": "Gold Master",
9+
"size": 650,
10+
"is_public": false,
11+
"type": "manual",
12+
"vendor": null,
13+
"eol": "2026-07-01T04:00:00",
14+
"expiry": "2026-08-01T04:00:00",
15+
"updated": "2020-07-01T04:00:00",
16+
"capabilities": ["cloud-init"],
17+
"tags": ["tests"],
18+
"total_size": 1300,
19+
"regions": [
20+
{
21+
"region": "us-east",
22+
"status": "available"
23+
},
24+
{
25+
"region": "us-west",
26+
"status": "pending replication"
27+
}
28+
]
29+
}

test/fixtures/images_upload.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"type": "manual",
1515
"updated": "2021-08-14T22:44:02",
1616
"vendor": "Debian",
17-
"capabilities": ["cloud-init"]
17+
"capabilities": ["cloud-init"],
18+
"tags": ["test_tag", "test2"]
1819
},
1920
"upload_to": "https://linode.com/"
2021
}

test/integration/conftest.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ def get_random_label():
3434
return label
3535

3636

37-
def get_region(client: LinodeClient, capabilities: Set[str] = None):
37+
def get_region(
38+
client: LinodeClient, capabilities: Set[str] = None, site_type: str = None
39+
):
3840
region_override = os.environ.get(ENV_REGION_OVERRIDE)
3941

4042
# Allow overriding the target test region
@@ -48,6 +50,9 @@ def get_region(client: LinodeClient, capabilities: Set[str] = None):
4850
v for v in regions if set(capabilities).issubset(v.capabilities)
4951
]
5052

53+
if site_type is not None:
54+
regions = [v for v in regions if v.site_type == site_type]
55+
5156
return random.choice(regions)
5257

5358

0 commit comments

Comments
 (0)