inmanta-module-openstack 5.0.0__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.
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/PKG-INFO +1 -1
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/SOURCES.txt +6 -1
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/__init__.py +34 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/common/base.py +29 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/host_port.py +4 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/virtual_machine.py +81 -1
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/user.py +2 -1
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/image/image.py +142 -51
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/model/_init.cf +226 -6
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/network.py +57 -2
- inmanta_module_openstack-5.0.2/inmanta_plugins/openstack/network/network_port.py +163 -0
- inmanta_module_openstack-5.0.2/inmanta_plugins/openstack/network/trunk_network.py +114 -0
- inmanta_module_openstack-5.0.2/inmanta_plugins/openstack/network/trunk_port.py +141 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/setup.cfg +1 -1
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/setup.cfg +1 -1
- inmanta_module_openstack-5.0.2/tests/test_block_devices.py +246 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_image.py +199 -23
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_neutron.py +196 -35
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_nova.py +4 -12
- inmanta_module_openstack-5.0.2/tests/test_trunk.py +292 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/MANIFEST.in +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/README.md +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/requires.txt +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/common/__init__.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/common/deps.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/__init__.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/compute/flavor.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/__init__.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/group.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/group_role.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/project.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/identity/role.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/image/__init__.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/__init__.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/router.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/router_port.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/security_group.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/subnet.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/quota/__init__.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/inmanta_plugins/openstack/quota/quota.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/pyproject.toml +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_dependency_handling.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_examples.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_flavor.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_keystone.py +0 -0
- {inmanta_module_openstack-5.0.0 → inmanta_module_openstack-5.0.2}/tests/test_quota.py +0 -0
|
@@ -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
|
|
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
|
|
73
|
-
# dictionary
|
|
74
|
-
|
|
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
|
-
|
|
143
|
-
if not resource.uri:
|
|
186
|
+
if not resource.uri and not resource.file:
|
|
144
187
|
raise InvalidOperation(
|
|
145
|
-
"
|
|
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
|
-
|
|
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
|
|
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(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
)
|