inmanta-module-openstack 5.0.1__tar.gz → 5.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/SOURCES.txt +6 -1
  4. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/__init__.py +34 -0
  5. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/common/base.py +29 -0
  6. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/host_port.py +4 -0
  7. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/virtual_machine.py +81 -1
  8. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/user.py +2 -1
  9. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/image/image.py +142 -51
  10. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/model/_init.cf +226 -6
  11. inmanta_module_openstack-5.0.2/inmanta_plugins/openstack/network/network_port.py +163 -0
  12. inmanta_module_openstack-5.0.2/inmanta_plugins/openstack/network/trunk_network.py +114 -0
  13. inmanta_module_openstack-5.0.2/inmanta_plugins/openstack/network/trunk_port.py +141 -0
  14. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/setup.cfg +1 -1
  15. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/setup.cfg +1 -1
  16. inmanta_module_openstack-5.0.2/tests/test_block_devices.py +246 -0
  17. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_image.py +199 -23
  18. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_neutron.py +63 -23
  19. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_nova.py +4 -12
  20. inmanta_module_openstack-5.0.2/tests/test_trunk.py +292 -0
  21. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/MANIFEST.in +0 -0
  22. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/README.md +0 -0
  23. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  24. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  25. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  26. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  27. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/common/__init__.py +0 -0
  28. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/common/deps.py +0 -0
  29. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  30. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/flavor.py +0 -0
  31. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  32. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/group.py +0 -0
  33. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  34. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  35. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/project.py +0 -0
  36. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/role.py +0 -0
  37. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/image/__init__.py +0 -0
  38. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/__init__.py +0 -0
  39. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  40. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/network.py +0 -0
  41. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  42. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/router.py +0 -0
  43. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/router_port.py +0 -0
  44. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/security_group.py +0 -0
  45. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/subnet.py +0 -0
  46. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  47. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  48. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/quota/quota.py +0 -0
  49. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/pyproject.toml +0 -0
  50. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_dependency_handling.py +0 -0
  51. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_examples.py +0 -0
  52. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_flavor.py +0 -0
  53. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_keystone.py +0 -0
  54. {inmanta_module_openstack-5.0.1 → inmanta_module_openstack-5.0.2}/tests/test_quota.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inmanta-module-openstack
3
- Version: 5.0.1
3
+ Version: 5.0.2
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inmanta-module-openstack
3
- Version: 5.0.1
3
+ Version: 5.0.2
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -30,14 +30,18 @@ inmanta_plugins/openstack/model/_init.cf
30
30
  inmanta_plugins/openstack/network/__init__.py
31
31
  inmanta_plugins/openstack/network/floating_ip.py
32
32
  inmanta_plugins/openstack/network/network.py
33
+ inmanta_plugins/openstack/network/network_port.py
33
34
  inmanta_plugins/openstack/network/qos_policy.py
34
35
  inmanta_plugins/openstack/network/router.py
35
36
  inmanta_plugins/openstack/network/router_port.py
36
37
  inmanta_plugins/openstack/network/security_group.py
37
38
  inmanta_plugins/openstack/network/subnet.py
38
39
  inmanta_plugins/openstack/network/subnet_v6.py
40
+ inmanta_plugins/openstack/network/trunk_network.py
41
+ inmanta_plugins/openstack/network/trunk_port.py
39
42
  inmanta_plugins/openstack/quota/__init__.py
40
43
  inmanta_plugins/openstack/quota/quota.py
44
+ tests/test_block_devices.py
41
45
  tests/test_dependency_handling.py
42
46
  tests/test_examples.py
43
47
  tests/test_flavor.py
@@ -45,4 +49,5 @@ tests/test_image.py
45
49
  tests/test_keystone.py
46
50
  tests/test_neutron.py
47
51
  tests/test_nova.py
48
- tests/test_quota.py
52
+ tests/test_quota.py
53
+ tests/test_trunk.py
@@ -29,6 +29,7 @@ from inmanta.plugins import PluginException, plugin
29
29
 
30
30
  IMAGES = {}
31
31
  FIND_IMAGE_RESULT = {}
32
+ IMAGE_SIZE_RESULT = {}
32
33
 
33
34
  LOGGER = logging.getLogger(__name__)
34
35
 
@@ -114,6 +115,39 @@ def find_image(
114
115
  conn.close()
115
116
 
116
117
 
118
+ @plugin
119
+ def image_size_gb(provider: "openstack::Provider", image_id: "string") -> "int":
120
+ """
121
+ Return the size of a Glance image in GB, rounded up.
122
+
123
+ :param provider: The OpenStack provider to connect to.
124
+ :param image_id: The Glance image ID (e.g. from std::getfact(image, "image_id")).
125
+ """
126
+
127
+ ident = (provider.name, image_id)
128
+ if ident in IMAGE_SIZE_RESULT:
129
+ return IMAGE_SIZE_RESULT[ident]
130
+
131
+ conn = openstack.connection.Connection(
132
+ auth_url=provider.connection_url,
133
+ username=provider.username,
134
+ password=provider.password,
135
+ project_name=provider.project_name,
136
+ user_domain_name=provider.user_domain_name,
137
+ project_domain_name=provider.project_domain_name,
138
+ verify_cert=provider.verify_cert,
139
+ )
140
+ try:
141
+ image = conn.image.get_image(image_id)
142
+ size_gb = math.ceil(image.size / (1024**3))
143
+ IMAGE_SIZE_RESULT[ident] = size_gb
144
+ return size_gb
145
+ except openstack.exceptions.ResourceNotFound:
146
+ raise PluginException(f"Image '{image_id}' not found in Glance.")
147
+ finally:
148
+ conn.close()
149
+
150
+
117
151
  FLAVORS = {}
118
152
  FIND_FLAVOR_RESULT = {}
119
153
 
@@ -239,6 +239,35 @@ class OpenStackHandler(CRUDHandler):
239
239
  routers = self._connection.network.routers(**query_params)
240
240
  return self.require_single_item(routers)
241
241
 
242
+ def get_trunk(self, project_id=None, name=None, trunk_id=None):
243
+ """
244
+ Retrieve a trunk based on name or id
245
+ """
246
+ if not name and not trunk_id:
247
+ raise Exception("Either a name or an id needs to be provided.")
248
+
249
+ query_params = {}
250
+ if project_id:
251
+ query_params["project_id"] = project_id
252
+ if name:
253
+ query_params["name"] = name
254
+ if trunk_id:
255
+ query_params["id"] = trunk_id
256
+
257
+ trunks = self._connection.network.trunks(**query_params)
258
+ return self.require_single_item(trunks)
259
+
260
+ def get_port_by_name(self, name, project_id=None):
261
+ """
262
+ Retrieve a single neutron port by name, optionally scoped to a project
263
+ """
264
+ query_params = {"name": name}
265
+ if project_id:
266
+ query_params["project_id"] = project_id
267
+
268
+ ports = self._connection.network.ports(**query_params)
269
+ return self.require_single_item(ports)
270
+
242
271
  def get_domain(self, domain_name: str | None):
243
272
  """
244
273
  Retrieve the domain by name
@@ -197,6 +197,10 @@ class HostPortHandler(OpenStackHandler):
197
197
  "admin_state_up": True,
198
198
  "name": resource.name,
199
199
  "network_id": self.network.id,
200
+ # Create the port in the resource's project. When the provider's auth
201
+ # project matches resource.project (the common case) this is a no-op;
202
+ # it only matters when an admin creates a port in another project.
203
+ "project_id": self.project_id,
200
204
  }
201
205
 
202
206
  if resource.address and not resource.dhcp:
@@ -29,6 +29,7 @@ from inmanta.agent.handler import (
29
29
  )
30
30
  from inmanta.execute import proxy
31
31
  from inmanta.resources import resource
32
+ from openstack.exceptions import ResourceFailure, ResourceTimeout
32
33
 
33
34
  from inmanta_plugins.openstack.common.base import (
34
35
  NotUniqueException,
@@ -55,6 +56,7 @@ class VirtualMachine(OpenstackResource):
55
56
  "config_drive",
56
57
  "metadata",
57
58
  "personality",
59
+ "block_devices",
58
60
  )
59
61
  name: str
60
62
  flavor: str
@@ -67,6 +69,7 @@ class VirtualMachine(OpenstackResource):
67
69
  config_drive: bool
68
70
  metadata: dict[str, str]
69
71
  personality: dict[str, str]
72
+ block_devices: list[dict]
70
73
 
71
74
  @staticmethod
72
75
  def get_key_name(exporter, vm):
@@ -111,6 +114,27 @@ class VirtualMachine(OpenstackResource):
111
114
  def get_security_groups(_, vm):
112
115
  return sorted([v.name for v in vm.security_groups])
113
116
 
117
+ @staticmethod
118
+ def get_block_devices(_, vm):
119
+ result = []
120
+ for bd in vm.block_devices:
121
+ entry = {
122
+ "source_type": bd.source_type,
123
+ "destination_type": bd.destination_type,
124
+ "device_type": bd.device_type,
125
+ "boot_index": bd.boot_index,
126
+ "delete_on_termination": bd.delete_on_termination,
127
+ }
128
+ if bd.source_type != "blank":
129
+ # non-blank source types require a uuid
130
+ entry["uuid"] = bd.uuid
131
+ if bd.volume_size is not None:
132
+ entry["volume_size"] = bd.volume_size
133
+ if bd.disk_bus is not None:
134
+ entry["disk_bus"] = bd.disk_bus
135
+ result.append(entry)
136
+ return result
137
+
114
138
 
115
139
  @provider("openstack::VirtualMachine", name="openstack")
116
140
  class VirtualMachineHandler(OpenStackHandler):
@@ -196,6 +220,53 @@ class VirtualMachineHandler(OpenStackHandler):
196
220
  server = self.get_server_by_name(resource.name, project_id)
197
221
  return server is not None
198
222
 
223
+ def _raise_server_error(self, ctx: HandlerContext, log_message: str) -> None:
224
+ """Re-fetch self.server, log its Nova fault, and raise.
225
+
226
+ Used both when a server is found in ERROR state by read_resource and
227
+ when it transitions to ERROR during a build. Re-fetching is necessary
228
+ because the list API used by get_server_by_name may omit the fault body.
229
+ The ERROR-state server must be deleted (manually or via purge) before
230
+ Inmanta will attempt to recreate it.
231
+ """
232
+ server = self._connection.compute.get_server(self.server.id)
233
+ fault = getattr(server, "fault", None) or {}
234
+ msg = fault.get("message", "unknown error")
235
+ details = fault.get("details", "")
236
+ ctx.error(
237
+ log_message,
238
+ fault_message=msg,
239
+ fault_details=details,
240
+ server_id=self.server.id,
241
+ )
242
+ raise Exception(
243
+ f"Server {self.server.id} ({self.server.name}) is in ERROR state: {msg}"
244
+ ) from None
245
+
246
+ def _wait_for_active(self, ctx: HandlerContext, timeout: int = 600) -> None:
247
+ """Wait for self.server to reach ACTIVE, raise on ERROR or timeout."""
248
+ try:
249
+ self.server = self._connection.compute.wait_for_server(
250
+ self.server, wait=timeout
251
+ )
252
+ except ResourceFailure:
253
+ self._raise_server_error(ctx, "VM entered ERROR state during build")
254
+ except ResourceTimeout:
255
+ server = self._connection.compute.get_server(self.server.id)
256
+ status = getattr(server, "status", "unknown")
257
+ task_state = getattr(server, "task_state", "unknown")
258
+ ctx.error(
259
+ "Timed out waiting for VM to become ACTIVE",
260
+ timeout_seconds=timeout,
261
+ current_status=status,
262
+ current_task_state=task_state,
263
+ server_id=self.server.id,
264
+ )
265
+ raise Exception(
266
+ f"Server {self.server.id} did not reach ACTIVE within {timeout}s "
267
+ f"(status={status}, task_state={task_state})"
268
+ ) from None
269
+
199
270
  def read_resource(self, ctx: HandlerContext, resource: VirtualMachine) -> None:
200
271
  """
201
272
  This method will check what the status of the give resource is on
@@ -205,6 +276,11 @@ class VirtualMachineHandler(OpenStackHandler):
205
276
  if self.server is None:
206
277
  raise ResourcePurged()
207
278
 
279
+ if self.server.status == "ERROR":
280
+ self._raise_server_error(
281
+ ctx, "Server is in ERROR state; delete it to allow redeployment"
282
+ )
283
+
208
284
  resource.purged = False
209
285
  self._connection.compute.fetch_server_security_groups(self.server)
210
286
  resource.security_groups = [sg["name"] for sg in self.server.security_groups]
@@ -227,7 +303,6 @@ class VirtualMachineHandler(OpenStackHandler):
227
303
  sg_list = self._build_sg_list(resource)
228
304
  args = dict(
229
305
  name=resource.name,
230
- image_id=resource.image,
231
306
  flavor_id=flavor.id,
232
307
  networks=nics,
233
308
  security_groups=sg_list,
@@ -237,12 +312,17 @@ class VirtualMachineHandler(OpenStackHandler):
237
312
  metadata=resource.metadata,
238
313
  files=resource.personality,
239
314
  )
315
+ if resource.block_devices:
316
+ args["block_device_mapping_v2"] = list(resource.block_devices)
317
+ else:
318
+ args["image_id"] = resource.image
240
319
  ctx.info(
241
320
  f"Creating server with name {resource.name} and options",
242
321
  options=args,
243
322
  )
244
323
 
245
324
  self.server = self._connection.compute.create_server(**args)
325
+ self._wait_for_active(ctx)
246
326
 
247
327
  self._set_facts(ctx)
248
328
  # Note: attach/detach of interfaces handled by Neutron or by passing ports at creation time
@@ -52,7 +52,8 @@ class UserHandler(OpenStackHandler):
52
52
 
53
53
  resource.purged = False
54
54
  resource.enabled = user.is_enabled
55
- resource.email = user.email
55
+ if resource.email is not None:
56
+ resource.email = user.email
56
57
 
57
58
  self.user = user
58
59
  ctx.set_fact("user_id", user.id, expires=False)
@@ -17,7 +17,6 @@ Contact: code@inmanta.com
17
17
  """
18
18
 
19
19
  import contextlib
20
- import time
21
20
  from typing import Any, Optional
22
21
  from urllib import request as urllib_request
23
22
 
@@ -43,6 +42,7 @@ class Image(OpenstackAdminResource):
43
42
  fields = (
44
43
  "name",
45
44
  "uri",
45
+ "file",
46
46
  "container_format",
47
47
  "disk_format",
48
48
  "image_id",
@@ -57,6 +57,7 @@ class Image(OpenstackAdminResource):
57
57
  disk_format: str
58
58
  metadata: dict[str, Any]
59
59
  uri: Optional[str]
60
+ file: Optional[str]
60
61
  skip_on_deploy: bool
61
62
  purge_on_delete: bool
62
63
  visibility: str
@@ -67,15 +68,53 @@ class Image(OpenstackAdminResource):
67
68
  @provider("openstack::Image", name="openstack")
68
69
  class ImageHandler(OpenStackHandler):
69
70
  image: OSImage
71
+ # Glance image statuses: https://docs.openstack.org/glance/latest/user/statuses.html
72
+ FAILURE_STATUSES = ["killed", "deleted", "pending_delete", "deactivated"]
70
73
 
71
74
  def _get_metadata_property(self, key: str) -> Any:
72
- # openstacksdk exposes custom/extra fields via the resource's "properties"
73
- # dictionary rather than as direct attributes.
74
- return getattr(self.image, "properties", {}).get(key)
75
+ # openstacksdk stores unknown/custom fields in the resource's "properties"
76
+ # dictionary (e.g. hw_cdrom_bus), but glance properties it declares as
77
+ # typed attributes (e.g. vm_mode, hw_disk_bus, hw_vif_model) are consumed
78
+ # out of that dictionary and only reachable as attributes. Check both.
79
+ properties = getattr(self.image, "properties", None) or {}
80
+ if key in properties:
81
+ return properties[key]
82
+ return getattr(self.image, key, None)
75
83
 
76
84
  def _resource_has_explicit_image_id(self, resource: Image) -> bool:
77
85
  return resource.image_id not in (None, "", "auto")
78
86
 
87
+ def _image_in_failed_state(self) -> bool:
88
+ if self.image.status in self.FAILURE_STATUSES:
89
+ return True
90
+ # Flag failed imports
91
+ return self.image.status == "queued" and bool(
92
+ self._get_metadata_property("os_glance_failed_import")
93
+ )
94
+
95
+ def _raise_image_error(self, ctx: handler.HandlerContext, log_message: str) -> None:
96
+ """Re-fetch self.image, log its failure details, and raise.
97
+
98
+ Used both when an image is found in a failed state by read_resource and
99
+ when it enters one while waiting for it to become active after create.
100
+ The failed image must be deleted (manually or via purge) before Inmanta
101
+ will attempt to recreate it.
102
+ """
103
+ self.image = self._connection.image.get_image(self.image.id)
104
+ failed_stores = self._get_metadata_property("os_glance_failed_import") or ""
105
+ ctx.error(
106
+ log_message,
107
+ image_status=self.image.status,
108
+ failed_import_stores=failed_stores,
109
+ image_id=self.image.id,
110
+ )
111
+ details = f"status={self.image.status}"
112
+ if failed_stores:
113
+ details += f", import failed for store(s): {failed_stores}"
114
+ raise Exception(
115
+ f"Image {self.image.id} ({self.image.name}) is in a failed state ({details})"
116
+ ) from None
117
+
79
118
  def _get_image(self, resource: Image) -> Optional[OSImage]:
80
119
  if self._resource_has_explicit_image_id(resource):
81
120
  try:
@@ -108,6 +147,11 @@ class ImageHandler(OpenStackHandler):
108
147
 
109
148
  self.image = image
110
149
 
150
+ if self._image_in_failed_state():
151
+ self._raise_image_error(
152
+ ctx, "Image is in a failed state; delete it to allow redeployment"
153
+ )
154
+
111
155
  if self._resource_has_explicit_image_id(resource):
112
156
  resource.image_id = self.image.id
113
157
 
@@ -139,12 +183,15 @@ class ImageHandler(OpenStackHandler):
139
183
  "When using image_id to manage an existing image, the image must already exist in OpenStack."
140
184
  )
141
185
 
142
- # URI is required to create a new image
143
- if not resource.uri:
186
+ if not resource.uri and not resource.file:
144
187
  raise InvalidOperation(
145
- "The 'uri' attribute is required when creating a new image. "
188
+ "Either 'uri' or 'file' is required when creating a new image. "
146
189
  "To manage an existing image, specify its image_id."
147
190
  )
191
+ if resource.uri and resource.file:
192
+ raise InvalidOperation(
193
+ "Only one of 'uri' or 'file' can be specified when creating an image."
194
+ )
148
195
 
149
196
  # Discover import methods
150
197
  methods = self._import_methods()
@@ -159,7 +206,10 @@ class ImageHandler(OpenStackHandler):
159
206
  }
160
207
 
161
208
  metadata = dict(resource.metadata)
162
- metadata["uri"] = resource.uri
209
+ if resource.uri:
210
+ metadata["uri"] = resource.uri
211
+ elif resource.file:
212
+ metadata["source_file"] = resource.file
163
213
  metadata["inmanta_managed_keys"] = ",".join(resource.metadata.keys())
164
214
 
165
215
  self.image = self._connection.image.create_image(
@@ -170,7 +220,7 @@ class ImageHandler(OpenStackHandler):
170
220
  ctx.set_fact("image_id", self.image.id, expires=False)
171
221
 
172
222
  # Import using whichever supported method
173
- self._import_image_via_methods(resource.uri, set(methods))
223
+ self._import_image_via_methods(resource, set(methods))
174
224
 
175
225
  if resource.skip_on_deploy:
176
226
  raise SkipResource(
@@ -178,31 +228,45 @@ class ImageHandler(OpenStackHandler):
178
228
  ", but not waiting for it to deploy, because skip_on_deploy is set"
179
229
  )
180
230
  else:
181
- self._wait_for_image_to_become_active()
231
+ self._wait_for_image_to_become_active(ctx)
182
232
  ctx.set_created()
183
233
 
184
- def _wait_for_image_to_become_active(self, timeout: int = 60) -> None:
185
- """
186
- Poll until the Openstack image backing this resource reaches `active`.
187
-
188
- :param timeout: An exception is raised when the image doesn't enter the `active` state
189
- after this amount of seconds.
190
- """
191
- if timeout < 0:
192
- raise ValueError(f"Timeout cannot be negative: {timeout}")
193
-
194
- start = time.time()
195
- image: Optional[OSImage] = None
196
- while time.time() < start + timeout:
197
- image = self._connection.image.get_image(self.image)
198
- if image and image.status == "active":
199
- return
200
- time.sleep(0.1)
201
-
202
- raise Exception(
203
- f"A timeout occurred while waiting for image {self.image.name} to enter the `active` state "
204
- f"(status={image.status if image else None})"
205
- )
234
+ def _wait_for_image_to_become_active(
235
+ self, ctx: handler.HandlerContext, timeout: int = 60
236
+ ) -> None:
237
+ """Wait for self.image to reach `active`, raise on a failure status or timeout."""
238
+ try:
239
+ self.image = self._connection.image.wait_for_status(
240
+ self.image,
241
+ status="active",
242
+ failures=self.FAILURE_STATUSES,
243
+ interval=2,
244
+ wait=timeout,
245
+ )
246
+ except openstack.exceptions.ResourceFailure:
247
+ self._raise_image_error(
248
+ ctx,
249
+ "Image entered a failed state while waiting for it to become active",
250
+ )
251
+ except openstack.exceptions.ResourceTimeout:
252
+ # A failed interoperable image import returns the image to "queued"
253
+ # rather than a failure status, so it surfaces here instead of as
254
+ # a ResourceFailure.
255
+ self.image = self._connection.image.get_image(self.image.id)
256
+ if self._image_in_failed_state():
257
+ self._raise_image_error(
258
+ ctx, "Image import failed while waiting for it to become active"
259
+ )
260
+ ctx.error(
261
+ "Timed out waiting for image to become active",
262
+ timeout_seconds=timeout,
263
+ current_status=self.image.status,
264
+ image_id=self.image.id,
265
+ )
266
+ raise Exception(
267
+ f"Image {self.image.id} ({self.image.name}) did not reach `active` "
268
+ f"within {timeout}s (status={self.image.status})"
269
+ ) from None
206
270
 
207
271
  def delete_resource(self, ctx: handler.HandlerContext, resource: Image) -> None:
208
272
  self._connection.image.delete_image(self.image, ignore_missing=True)
@@ -250,26 +314,53 @@ class ImageHandler(OpenStackHandler):
250
314
  ctx.set_updated()
251
315
 
252
316
  def _import_methods(self) -> set[str]:
253
- """Return supported import methods ('web-download' / 'glance-direct')."""
317
+ """Return the interoperable import methods enabled on this cloud that we
318
+ support ('web-download' / 'glance-direct').
319
+
320
+ May be empty: a local file can still be uploaded directly, without any
321
+ import method (see _import_image_via_methods).
322
+ """
254
323
  info = self._connection.image.get_import_info()
255
324
  methods = set(
256
325
  info.import_methods["value"]
257
326
  ) # {'description','type','value':[...]}
258
- supported = methods & {"web-download", "glance-direct"}
259
-
260
- if not supported:
261
- raise InvalidOperation(
262
- "Glance image import is not usable on this cloud: "
263
- f"enabled methods = {methods}. Need 'web-download' or 'glance-direct'."
264
- )
265
- return supported
266
-
267
- def _import_image_via_methods(self, uri: str, methods: set[str]) -> None:
268
- if "web-download" in methods:
269
- self._connection.image.import_image(
270
- self.image, method="web-download", uri=uri
271
- )
272
- elif "glance-direct" in methods:
273
- with contextlib.closing(urllib_request.urlopen(uri)) as resp:
274
- self._connection.image.stage_image(self.image, data=resp) # streamed
275
- self._connection.image.import_image(self.image, method="glance-direct")
327
+ return methods & {"web-download", "glance-direct"}
328
+
329
+ def _import_image_via_methods(self, resource: Image, methods: set[str]) -> None:
330
+ """Upload the image's data to glance.
331
+
332
+ FILE: a local file on the agent machine. Uploaded with the classic direct
333
+ data upload (PUT /v2/images/{id}/file), which is independent of the
334
+ interoperable image import API and the methods it enables. The
335
+ 'glance-direct' import method is used instead when the cloud exposes
336
+ it (e.g. multi-store backends that disable direct upload).
337
+ URI: a remote location. Instruct the cloud to download the image directly
338
+ from the URI ('web-download'), or stream it through the orchestrator
339
+ if 'glance-direct' is the only supported method.
340
+ """
341
+ if resource.file:
342
+ if "glance-direct" in methods:
343
+ with open(resource.file, "rb") as f:
344
+ self._connection.image.stage_image(self.image, data=f)
345
+ self._connection.image.import_image(self.image, method="glance-direct")
346
+ else:
347
+ with open(resource.file, "rb") as f:
348
+ response = self.image.upload(self._connection.image, data=f)
349
+ openstack.exceptions.raise_from_response(response)
350
+ elif resource.uri:
351
+ if "web-download" in methods:
352
+ self._connection.image.import_image(
353
+ self.image, method="web-download", uri=resource.uri
354
+ )
355
+ elif "glance-direct" in methods:
356
+ with contextlib.closing(urllib_request.urlopen(resource.uri)) as resp:
357
+ self._connection.image.stage_image(
358
+ self.image, data=resp
359
+ ) # streamed
360
+ self._connection.image.import_image(self.image, method="glance-direct")
361
+ else:
362
+ raise InvalidOperation(
363
+ "Importing an image from a URI requires the 'web-download' or "
364
+ "'glance-direct' import method, but neither is enabled on this "
365
+ f"cloud (enabled methods: {methods})."
366
+ )