inmanta-module-openstack 6.1.0__tar.gz → 6.1.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 (70) hide show
  1. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/SOURCES.txt +7 -3
  4. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/common/base.py +21 -6
  5. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/flavor.py +113 -3
  6. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/virtual_machine.py +120 -68
  7. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/model/_init.cf +35 -2
  8. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/network_port.py +51 -1
  9. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/router.py +15 -5
  10. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/setup.cfg +1 -1
  11. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/setup.cfg +1 -1
  12. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_block_devices.py +1 -1
  13. inmanta_module_openstack-6.1.2/tests/test_common_base.py +66 -0
  14. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_flavor.py +329 -0
  15. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_host_aggregate.py +3 -3
  16. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_image.py +5 -31
  17. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_keystone.py +1 -1
  18. inmanta_module_openstack-6.1.2/tests/test_network.py +366 -0
  19. inmanta_module_openstack-6.1.2/tests/test_network_port.py +684 -0
  20. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_network_port_fixed_ips.py +1 -1
  21. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_network_port_security_groups.py +1 -1
  22. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_nova.py +37 -7
  23. inmanta_module_openstack-6.1.2/tests/test_qos_policy.py +156 -0
  24. inmanta_module_openstack-6.1.2/tests/test_router.py +263 -0
  25. inmanta_module_openstack-6.1.2/tests/test_security_group.py +406 -0
  26. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_security_group_ethertype.py +2 -2
  27. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_server_group.py +114 -88
  28. inmanta_module_openstack-6.1.2/tests/test_subnet.py +378 -0
  29. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_trunk.py +3 -2
  30. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_vm_boot_nic.py +127 -96
  31. inmanta_module_openstack-6.1.0/tests/test_network_port_missing_network.py +0 -176
  32. inmanta_module_openstack-6.1.0/tests/test_network_port_shared_network.py +0 -263
  33. inmanta_module_openstack-6.1.0/tests/test_neutron.py +0 -1633
  34. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/MANIFEST.in +0 -0
  35. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/README.md +0 -0
  36. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  37. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  38. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  39. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  40. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/__init__.py +0 -0
  41. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/common/__init__.py +0 -0
  42. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/common/deps.py +0 -0
  43. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  44. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/host_aggregate.py +0 -0
  45. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/server_group.py +0 -0
  46. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  47. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/group.py +0 -0
  48. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  49. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  50. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/project.py +0 -0
  51. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/role.py +0 -0
  52. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/user.py +0 -0
  53. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/image/__init__.py +0 -0
  54. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/image/image.py +0 -0
  55. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/__init__.py +0 -0
  56. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  57. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/network.py +0 -0
  58. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  59. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/router_port.py +0 -0
  60. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/security_group.py +0 -0
  61. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/subnet.py +0 -0
  62. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  63. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  64. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  65. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  66. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/quota/quota.py +0 -0
  67. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/pyproject.toml +0 -0
  68. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_dependency_handling.py +0 -0
  69. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.2}/tests/test_examples.py +0 -0
  70. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.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: 6.1.0
3
+ Version: 6.1.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: 6.1.0
3
+ Version: 6.1.2
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -43,20 +43,24 @@ inmanta_plugins/openstack/network/trunk_port.py
43
43
  inmanta_plugins/openstack/quota/__init__.py
44
44
  inmanta_plugins/openstack/quota/quota.py
45
45
  tests/test_block_devices.py
46
+ tests/test_common_base.py
46
47
  tests/test_dependency_handling.py
47
48
  tests/test_examples.py
48
49
  tests/test_flavor.py
49
50
  tests/test_host_aggregate.py
50
51
  tests/test_image.py
51
52
  tests/test_keystone.py
53
+ tests/test_network.py
54
+ tests/test_network_port.py
52
55
  tests/test_network_port_fixed_ips.py
53
- tests/test_network_port_missing_network.py
54
56
  tests/test_network_port_security_groups.py
55
- tests/test_network_port_shared_network.py
56
- tests/test_neutron.py
57
57
  tests/test_nova.py
58
+ tests/test_qos_policy.py
58
59
  tests/test_quota.py
60
+ tests/test_router.py
61
+ tests/test_security_group.py
59
62
  tests/test_security_group_ethertype.py
60
63
  tests/test_server_group.py
64
+ tests/test_subnet.py
61
65
  tests/test_trunk.py
62
66
  tests/test_vm_boot_nic.py
@@ -175,15 +175,19 @@ class OpenStackHandler(CRUDHandler):
175
175
  """
176
176
  Retrieve the id of a project based on the given name
177
177
  """
178
+ # A project-scoped token already knows its own project id. Use it before
179
+ # querying Keystone: non-admin users may be forbidden to get/list projects.
180
+ current_project_id = self._connection.current_project_id
181
+ if name == resource.admin_project_name and current_project_id:
182
+ return current_project_id
183
+
178
184
  project = self._connection.identity.find_project(name)
179
185
  if project:
180
186
  return project.id
181
187
 
182
188
  # fallback for non-admins: if we’re scoped to a project and its name matches
183
- if self._connection.current_project_id:
184
- current = self._connection.identity.get_project(
185
- self._connection.current_project_id
186
- )
189
+ if current_project_id:
190
+ current = self._connection.identity.get_project(current_project_id)
187
191
  if current and current.name == name:
188
192
  return current.id
189
193
 
@@ -342,19 +346,30 @@ class OpenStackHandler(CRUDHandler):
342
346
  return None
343
347
  return self._connection.identity.find_domain(domain_name)
344
348
 
345
- def get_server_by_name(self, name, project_id=None):
349
+ def get_server_by_name(self, name, project_id=None, all_projects=False):
346
350
  """
347
351
  Retrieve a server by name
348
352
  """
349
353
  additional_args = {}
350
354
  if project_id is not None:
351
- additional_args["all_projects"] = True
352
355
  additional_args["project_id"] = project_id
356
+ if all_projects:
357
+ additional_args["all_projects"] = True
353
358
  # regex for exact match
354
359
  servers = self._connection.compute.servers(name=f"^{name}$", **additional_args)
355
360
 
356
361
  return self.require_single_item(servers)
357
362
 
363
+ def get_project_server(self, resource, name, project_id):
364
+ """
365
+ Retrieve a server by name in the project of the resource
366
+ """
367
+ # Nova lists only the servers of the token's own project. Servers of another
368
+ # project need all_projects, the project_id filter is ignored without it.
369
+ if resource.project == resource.admin_project_name:
370
+ return self.get_server_by_name(name)
371
+ return self.get_server_by_name(name, project_id, all_projects=True)
372
+
358
373
  def _get_qos_policy(self, **query_params):
359
374
  try:
360
375
  policies = self._connection.network.qos_policies(**query_params)
@@ -19,7 +19,7 @@ Contact: code@inmanta.com
19
19
  from typing import Any
20
20
 
21
21
  from inmanta.agent import handler
22
- from inmanta.agent.handler import ResourcePurged, cache, provider
22
+ from inmanta.agent.handler import ResourcePurged, SkipResource, cache, provider
23
23
  from inmanta.resources import resource
24
24
  from openstack.compute.v2.flavor import Flavor as SdkFlavor
25
25
 
@@ -47,6 +47,7 @@ class Flavor(OpenstackAdminResource):
47
47
  "rxtx_factor",
48
48
  "is_public",
49
49
  "description",
50
+ "allowed_projects",
50
51
  "extra_specs",
51
52
  )
52
53
  name: str
@@ -60,6 +61,17 @@ class Flavor(OpenstackAdminResource):
60
61
  is_public: bool
61
62
  description: str | None
62
63
  extra_specs: dict[str, Any] | None
64
+ allowed_projects: list[dict[str, str]]
65
+
66
+ @staticmethod
67
+ def get_allowed_projects(_, obj):
68
+ return sorted(
69
+ [
70
+ {"name": project.name, "domain": project.domain}
71
+ for project in obj.allowed_projects
72
+ ],
73
+ key=lambda project: (project["domain"], project["name"]),
74
+ )
63
75
 
64
76
  @staticmethod
65
77
  def get_extra_specs(_, obj):
@@ -72,7 +84,7 @@ class Flavor(OpenstackAdminResource):
72
84
  class FlavorHandler(OpenStackHandler):
73
85
  # Fields Nova can change on an existing flavor. Any other change requires
74
86
  # deleting and recreating it.
75
- IN_PLACE_FIELDS = {"description", "extra_specs"}
87
+ IN_PLACE_FIELDS = {"description", "extra_specs", "allowed_projects"}
76
88
 
77
89
  # Set by read_resource, which raises ResourcePurged when the flavor is gone,
78
90
  # so the other handler methods can rely on it being there.
@@ -163,14 +175,17 @@ class FlavorHandler(OpenStackHandler):
163
175
  # value in place keeps whatever is on the flavor out of the diff.
164
176
  if resource.description is not None:
165
177
  resource.description = self.flavor.description or ""
178
+ resource.allowed_projects = self._current_allowed_projects(resource)
166
179
  if resource.extra_specs is not None:
167
180
  resource.extra_specs = self.flavor.extra_specs
168
181
  self._set_facts(ctx)
169
182
 
170
183
  def create_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
184
+ desired_project_ids = self._desired_project_ids(resource)
171
185
  self.flavor = self._create_flavor(
172
186
  resource, resource.description, resource.extra_specs
173
187
  )
188
+ self._update_allowed_projects(desired_project_ids)
174
189
  self._set_facts(ctx)
175
190
  ctx.set_created()
176
191
 
@@ -187,6 +202,7 @@ class FlavorHandler(OpenStackHandler):
187
202
  """Update flavor parameters.
188
203
 
189
204
  Nova lets us change the description and the extra specs on an existing
205
+ flavor. Project access can also be reconciled without recreating the
190
206
  flavor. The sizing fields are immutable, so a change to any of those is
191
207
  applied by deleting the existing flavor and recreating it. That is only
192
208
  safe when OpenStack assigns the id: an explicit flavor_id would collide
@@ -196,7 +212,10 @@ class FlavorHandler(OpenStackHandler):
196
212
  never updated, and a recreate carries the live values over rather than
197
213
  dropping them.
198
214
  """
199
- if not set(changes) - self.IN_PLACE_FIELDS:
215
+ recreate_flavor = bool(set(changes) - self.IN_PLACE_FIELDS)
216
+ desired_project_ids = self._desired_project_ids(resource)
217
+
218
+ if not recreate_flavor:
200
219
  if "description" in changes:
201
220
  self._connection.compute.update_flavor(
202
221
  self.flavor, description=resource.description
@@ -204,6 +223,8 @@ class FlavorHandler(OpenStackHandler):
204
223
  # Unmanaged fields never end up in changes, so both are set here.
205
224
  if "extra_specs" in changes and resource.extra_specs is not None:
206
225
  self._update_extra_specs(resource.extra_specs)
226
+ if "allowed_projects" in changes:
227
+ self._update_allowed_projects(desired_project_ids)
207
228
  ctx.set_updated()
208
229
  return
209
230
 
@@ -232,9 +253,98 @@ class FlavorHandler(OpenStackHandler):
232
253
  )
233
254
  self._delete_flavor()
234
255
  self.flavor = self._create_flavor(resource, description, extra_specs)
256
+ self._update_allowed_projects(desired_project_ids)
235
257
  self._set_facts(ctx)
236
258
  ctx.set_updated()
237
259
 
260
+ def _flavor_accesses(self) -> list[dict[str, str]]:
261
+ if self.flavor.is_public:
262
+ return []
263
+ return list(self._connection.compute.get_flavor_access(self.flavor))
264
+
265
+ @staticmethod
266
+ def _project_id_from_access(access: dict[str, str]) -> str | None:
267
+ return access.get("tenant_id") or access.get("project_id")
268
+
269
+ def _get_project(self, project: dict[str, str]):
270
+ domain_name = project["domain"]
271
+ domain = self._connection.identity.find_domain(domain_name)
272
+ if domain is None:
273
+ raise SkipResource(f"The domain {domain_name} does not exist.")
274
+
275
+ project_name = project["name"]
276
+ openstack_project = self._connection.identity.find_project(
277
+ project_name,
278
+ domain_id=domain.id,
279
+ )
280
+ if openstack_project is None:
281
+ raise SkipResource(
282
+ f"The project {project_name} does not exist in domain {domain_name}."
283
+ )
284
+ return openstack_project
285
+
286
+ def _current_allowed_projects(self, resource: Flavor) -> list[dict[str, str]]:
287
+ if resource.is_public:
288
+ return []
289
+
290
+ projects = []
291
+ for access in self._flavor_accesses():
292
+ project_id = self._project_id_from_access(access)
293
+ if project_id is None:
294
+ continue
295
+
296
+ project = self._connection.identity.find_project(
297
+ project_id, ignore_missing=True
298
+ )
299
+ if project is None:
300
+ # Nova keeps the access row when the project is deleted in Keystone.
301
+ # Report it as an unknown grant so the update revokes it.
302
+ projects.append({"name": project_id, "domain": ""})
303
+ continue
304
+ domain = self._connection.identity.find_domain(
305
+ project.domain_id, ignore_missing=True
306
+ )
307
+ projects.append(
308
+ {"name": project.name, "domain": domain.name if domain else ""}
309
+ )
310
+ return sorted(
311
+ projects, key=lambda project: (project["domain"], project["name"])
312
+ )
313
+
314
+ def _desired_project_ids(self, resource: Flavor) -> set[str]:
315
+ """Resolve every desired project before touching the flavor, so an
316
+ unresolvable project skips the resource instead of leaving it half
317
+ applied (flavor created/recreated but with a partial access list).
318
+ """
319
+ if resource.is_public and resource.allowed_projects:
320
+ raise ValueError(
321
+ "Project access can only be configured on a private flavor."
322
+ )
323
+ return {self._get_project(project).id for project in resource.allowed_projects}
324
+
325
+ def _update_allowed_projects(self, desired_project_ids: set[str]) -> None:
326
+ current_project_ids = {
327
+ project_id
328
+ for access in self._flavor_accesses()
329
+ if (project_id := self._project_id_from_access(access)) is not None
330
+ }
331
+
332
+ for project_id in desired_project_ids - current_project_ids:
333
+ if hasattr(self._connection.compute, "flavor_add_tenant_access"):
334
+ self._connection.compute.flavor_add_tenant_access(
335
+ self.flavor, project_id
336
+ )
337
+ else:
338
+ self._connection.compute.add_flavor_access(self.flavor, project_id)
339
+
340
+ for project_id in current_project_ids - desired_project_ids:
341
+ if hasattr(self._connection.compute, "flavor_remove_tenant_access"):
342
+ self._connection.compute.flavor_remove_tenant_access(
343
+ self.flavor, project_id
344
+ )
345
+ else:
346
+ self._connection.compute.remove_flavor_access(self.flavor, project_id)
347
+
238
348
  def _set_facts(self, ctx):
239
349
  ctx.set_fact("flavor_id", self.flavor.id)
240
350
  ctx.set_fact("is_disabled", str(self.flavor.is_disabled))
@@ -17,7 +17,7 @@ Contact: code@inmanta.com
17
17
  """
18
18
 
19
19
  import time
20
- import traceback
20
+ from collections.abc import Callable
21
21
  from typing import Any
22
22
 
23
23
  from inmanta import ast
@@ -30,7 +30,7 @@ from inmanta.agent.handler import (
30
30
  )
31
31
  from inmanta.execute import proxy
32
32
  from inmanta.resources import resource
33
- from openstack.exceptions import NotFoundException, ResourceFailure, ResourceTimeout
33
+ from openstack.exceptions import NotFoundException
34
34
 
35
35
  from inmanta_plugins.openstack.common.base import (
36
36
  NotUniqueException,
@@ -191,17 +191,15 @@ class VirtualMachine(OpenstackResource):
191
191
 
192
192
  @provider("openstack::VirtualMachine", name="openstack")
193
193
  class VirtualMachineHandler(OpenStackHandler):
194
- @cache(timeout=1, cache_none=False)
195
194
  def _get_server(self, ctx, resource):
196
195
  project_id = self.get_project_id(resource, resource.project)
197
- server = self.get_server_by_name(resource.name, project_id)
198
- if resource.project != resource.admin_project_name:
199
- ctx.exception(
200
- f"Unable to retrieve server list with a scoped login on project {resource.admin_project_name}, "
201
- f"for project {resource.project}. This only works with admin credentials.",
202
- traceback=traceback.format_exc(),
196
+ server = self.get_project_server(resource, resource.name, project_id)
197
+ if server is None:
198
+ ctx.debug(
199
+ "No server named %(name)s found in project %(project)s.",
200
+ name=resource.name,
201
+ project=resource.project,
203
202
  )
204
- return None
205
203
  return server
206
204
 
207
205
  @cache(timeout=10, cache_none=False)
@@ -298,21 +296,18 @@ class VirtualMachineHandler(OpenStackHandler):
298
296
  self._connection.compute.create_keypair(**keypair_args)
299
297
  ctx.info(f"Created a new keypair with name {resource.key_name}")
300
298
 
301
- def _server_with_name_exists(self, resource):
302
- project_id = self.get_project_id(resource, resource.project)
303
- server = self.get_server_by_name(resource.name, project_id)
304
- return server is not None
299
+ def _bound_ports(self) -> list:
300
+ """Ports that are still bound to self.server."""
301
+ return list(self._connection.network.ports(device_id=self.server.id))
305
302
 
306
- def _raise_server_error(self, ctx: HandlerContext, log_message: str) -> None:
307
- """Re-fetch self.server, log its Nova fault, and raise.
303
+ def _raise_server_error(
304
+ self, ctx: HandlerContext, server: Any, log_message: str
305
+ ) -> None:
306
+ """Log the Nova fault of `server` and raise.
308
307
 
309
- Used both when a server is found in ERROR state by read_resource and
310
- when it transitions to ERROR during a build. Re-fetching is necessary
311
- because the list API used by get_server_by_name may omit the fault body.
312
308
  The ERROR-state server must be deleted (manually or via purge) before
313
309
  Inmanta will attempt to recreate it.
314
310
  """
315
- server = self._connection.compute.get_server(self.server.id)
316
311
  fault = getattr(server, "fault", None) or {}
317
312
  msg = fault.get("message", "unknown error")
318
313
  details = fault.get("details", "")
@@ -320,12 +315,42 @@ class VirtualMachineHandler(OpenStackHandler):
320
315
  log_message,
321
316
  fault_message=msg,
322
317
  fault_details=details,
323
- server_id=self.server.id,
318
+ server_id=server.id,
324
319
  )
325
320
  raise Exception(
326
- f"Server {self.server.id} ({self.server.name}) is in ERROR state: {msg}"
321
+ f"Server {server.id} ({server.name}) is in ERROR state: {msg}"
327
322
  ) from None
328
323
 
324
+ def _poll_server(self, ctx: HandlerContext, operation: str) -> Any:
325
+ """Fetch self.server by id, None when it is not found.
326
+
327
+ Raises when Nova left it in ERROR state. An ERROR server that still has
328
+ a task state is left alone, Nova is still working on it.
329
+ """
330
+ try:
331
+ server = self._connection.compute.get_server(self.server.id)
332
+ except NotFoundException:
333
+ return None
334
+ if server.status == "ERROR" and not server.task_state:
335
+ self._raise_server_error(
336
+ ctx, server, f"VM entered ERROR state during {operation}"
337
+ )
338
+ return server
339
+
340
+ def _wait_until(
341
+ self, condition: Callable[[], bool], timeout: int, interval: int = 2
342
+ ) -> bool:
343
+ """Poll `condition` every `interval` seconds until it returns True.
344
+
345
+ Returns False when `timeout` seconds pass before the condition is met.
346
+ """
347
+ deadline = time.monotonic() + timeout
348
+ while not condition():
349
+ if time.monotonic() >= deadline:
350
+ return False
351
+ time.sleep(interval)
352
+ return True
353
+
329
354
  def _wait_for_status(
330
355
  self,
331
356
  ctx: HandlerContext,
@@ -339,40 +364,31 @@ class VirtualMachineHandler(OpenStackHandler):
339
364
  and is only used to build the error messages.
340
365
 
341
366
  Nova may briefly return a 404 for the instance while a resize is being
342
- applied; such transient NotFoundException errors are tolerated and polling
343
- resumes until the status is reached or the timeout elapses.
367
+ applied, this is treated as not ready yet.
344
368
  """
345
- deadline = time.monotonic() + timeout
346
- while True:
347
- remaining = deadline - time.monotonic()
348
- if remaining <= 0:
349
- self._raise_status_timeout(ctx, status, timeout)
350
- try:
351
- self.server = self._connection.compute.wait_for_server(
352
- self.server, status=status, wait=max(1, int(remaining))
353
- )
354
- return
355
- except ResourceFailure:
356
- self._raise_server_error(
357
- ctx, f"VM entered ERROR state during {operation}"
358
- )
359
- except ResourceTimeout:
360
- self._raise_status_timeout(ctx, status, timeout)
361
- except NotFoundException:
362
- # The instance is temporarily not retrievable (e.g. mid-resize);
363
- # give it a moment and keep polling until the deadline.
364
- time.sleep(2)
369
+
370
+ def has_status() -> bool:
371
+ server = self._poll_server(ctx, operation)
372
+ if server is None:
373
+ return False
374
+ self.server = server
375
+ return server.status == status
376
+
377
+ if not self._wait_until(has_status, timeout):
378
+ self._raise_status_timeout(ctx, status, operation, timeout)
365
379
 
366
380
  def _raise_status_timeout(
367
- self, ctx: HandlerContext, status: str, timeout: int
381
+ self, ctx: HandlerContext, status: str, operation: str, timeout: int
368
382
  ) -> None:
369
- try:
370
- server = self._connection.compute.get_server(self.server.id)
371
- current_status = getattr(server, "status", "unknown")
372
- task_state = getattr(server, "task_state", "unknown")
373
- except NotFoundException:
383
+ # Fetch again so the error shows the state at timeout, not the last
384
+ # successful poll.
385
+ server = self._poll_server(ctx, operation)
386
+ if server is None:
374
387
  current_status = "not_found"
375
388
  task_state = "unknown"
389
+ else:
390
+ current_status = server.status
391
+ task_state = server.task_state
376
392
  ctx.error(
377
393
  f"Timed out waiting for VM to reach {status}",
378
394
  timeout_seconds=timeout,
@@ -385,6 +401,27 @@ class VirtualMachineHandler(OpenStackHandler):
385
401
  f"(status={current_status}, task_state={task_state})"
386
402
  ) from None
387
403
 
404
+ def _raise_ports_timeout(self, ctx: HandlerContext, timeout: int) -> None:
405
+ ports = [
406
+ {
407
+ "id": port.id,
408
+ "name": port.name,
409
+ "status": port.status,
410
+ "device_owner": port.device_owner,
411
+ }
412
+ for port in self._bound_ports()
413
+ ]
414
+ ctx.error(
415
+ "Timed out waiting for the ports of the deleted VM to be released",
416
+ timeout_seconds=timeout,
417
+ ports=ports,
418
+ server_id=self.server.id,
419
+ )
420
+ raise Exception(
421
+ f"Server {self.server.id} is deleted but its ports are still bound after "
422
+ f"{timeout}s: {', '.join(port['id'] for port in ports)}"
423
+ ) from None
424
+
388
425
  def _get_server_flavor_name(self, server) -> str:
389
426
  """Return the flavor name of `server`.
390
427
 
@@ -436,14 +473,24 @@ class VirtualMachineHandler(OpenStackHandler):
436
473
  """
437
474
  This method will check what the status of the give resource is on
438
475
  openstack.
476
+
477
+ A server in ERROR state raises, unless it is being deleted or purged.
439
478
  """
440
479
  self.server = self._get_server(ctx, resource)
441
480
  if self.server is None:
442
481
  raise ResourcePurged()
443
482
 
444
- if self.server.status == "ERROR":
483
+ # A server that is being deleted, or that is to be purged, goes away anyway.
484
+ if (
485
+ self.server.status == "ERROR"
486
+ and self.server.task_state != "deleting"
487
+ and not resource.purged
488
+ ):
445
489
  self._raise_server_error(
446
- ctx, "Server is in ERROR state; delete it to allow redeployment"
490
+ ctx,
491
+ self.server,
492
+ "Server is in ERROR state. Please check the OpenStack console for details. "
493
+ "Deployment cannot continue, please repair the server manually!",
447
494
  )
448
495
 
449
496
  resource.purged = False
@@ -497,6 +544,8 @@ class VirtualMachineHandler(OpenStackHandler):
497
544
  metadata=resource.metadata,
498
545
  files=resource.personality,
499
546
  )
547
+ # With block devices the boot disk, and its image, come from the mapping.
548
+ # image_id is only for booting from an image on local disk.
500
549
  if resource.block_devices:
501
550
  args["block_device_mapping_v2"] = list(resource.block_devices)
502
551
  else:
@@ -527,32 +576,36 @@ class VirtualMachineHandler(OpenStackHandler):
527
576
 
528
577
  def delete_resource(self, ctx: HandlerContext, resource: VirtualMachine) -> None:
529
578
  self._connection.compute.delete_server(self.server, ignore_missing=True)
579
+ timeout = 120
530
580
 
531
- def is_server_deletion_finished() -> bool:
532
- ports = list(self._connection.network.ports(device_id=self.server.id))
533
- if ports:
534
- return False
535
- return self._get_server(ctx, resource) is None
581
+ def is_server_deleted() -> bool:
582
+ return self._poll_server(ctx, "delete") is None
536
583
 
537
- # Wait until the server has been deleted
538
- count = 0
539
- ctx.info("Waiting until server is deleted.")
540
- while not is_server_deletion_finished() and count < 60:
541
- time.sleep(1)
542
- count += 1
584
+ # Nova releases the ports after the server is gone. A port that is still
585
+ # bound cannot be reused by a new server or by the NetworkPort handler.
586
+ def are_ports_released() -> bool:
587
+ return not self._bound_ports()
543
588
 
544
- if count >= 60:
545
- ctx.warning("Delete still in progress, giving up waiting.")
589
+ ctx.info("Waiting until server is deleted.")
590
+ if not self._wait_until(is_server_deleted, timeout):
591
+ self._raise_status_timeout(ctx, "DELETED", "delete", timeout)
592
+ if not self._wait_until(are_ports_released, timeout):
593
+ self._raise_ports_timeout(ctx, timeout)
546
594
 
595
+ ctx.info("Server was deleted.")
547
596
  ctx.set_purged()
548
597
 
549
598
  def update_resource(
550
599
  self, ctx: HandlerContext, changes: dict, resource: VirtualMachine
551
600
  ) -> None:
552
-
601
+ # read_resource lets through an ERROR server that is being deleted.
553
602
  if self.server.status == "ERROR":
554
603
  self._raise_server_error(
555
- ctx, "Server is in ERROR state; delete it to allow redeployment"
604
+ ctx,
605
+ self.server,
606
+ "Server is in ERROR state, thus cannot be updated. "
607
+ "Please check the OpenStack console for details. "
608
+ "Deployment cannot continue, please repair the server manually!",
556
609
  )
557
610
 
558
611
  self.validate_allowed_fields(
@@ -580,7 +633,6 @@ class VirtualMachineHandler(OpenStackHandler):
580
633
  self.server, remove_rule
581
634
  )
582
635
 
583
- self.server = self._get_server(ctx, resource)
584
636
  ctx.set_updated()
585
637
 
586
638
  def _set_facts(self, ctx):
@@ -173,8 +173,8 @@ entity GroupRole extends OpenStackResource:
173
173
  """
174
174
  string role
175
175
  string group_name
176
- string group_domain="Default"
177
- string domain=""
176
+ string group_domain = "Default"
177
+ string? domain = null
178
178
  end
179
179
  index GroupRole(project, group_name, group_domain, role, domain)
180
180
 
@@ -865,6 +865,7 @@ entity Flavor extends OpenStackResource:
865
865
  :attr description: A free-form description of the flavor. Unlike the other
866
866
  attributes, this one can be updated in place, without recreating the flavor.
867
867
  Leave it null to not manage the description at all.
868
+ :attr allowed_projects: Projects that can access this flavor when is_public is false
868
869
  :attr extra_specs: Set extra specs on a flavor. See https://docs.openstack.org/nova/rocky/admin/flavors.html
869
870
  This set is managed exhaustively: a spec present on the flavor but absent
870
871
  here is removed, so the default of an empty dict clears every spec. Set it
@@ -887,6 +888,38 @@ end
887
888
 
888
889
  implement Flavor using providerRequire
889
890
 
891
+ Flavor.allowed_projects [0:] -- Project.allowed_flavors [0:]
892
+ """
893
+ :rel allowed_projects: The projects that may use this flavor. Only meaningful when
894
+ ``is_public`` is false; setting it on a public flavor is an error, since Nova has
895
+ no access list for public flavors. The list is managed exhaustively: a grant on
896
+ the flavor but absent here is revoked, so the default of an empty relation clears
897
+ every grant.
898
+ """
899
+
900
+ implementation flavorProjectAccessDependencies for Flavor:
901
+ for project in self.allowed_projects:
902
+ std::assert(
903
+ project.provider == self.provider,
904
+ "Flavor '{{name}}' is on provider '{{provider.name}}' but allowed project '{{project.name}}' is on another provider."
905
+ )
906
+ if not self.purged and not project.purged:
907
+ self.requires += project
908
+ end
909
+ end
910
+ end
911
+
912
+ implement Flavor using flavorProjectAccessDependencies
913
+
914
+ implementation flavorProjectAccessOnPrivateOnly for Flavor:
915
+ std::assert(
916
+ self.is_public == false or std::count(self.allowed_projects) == 0,
917
+ "Flavor '{{name}}' is public, so it cannot have allowed_projects: Nova has no access list for public flavors."
918
+ )
919
+ end
920
+
921
+ implement Flavor using flavorProjectAccessOnPrivateOnly
922
+
890
923
  index HostAggregateMetadata(provider, aggregate, key)
891
924
  HostAggregateMetadata.provider [1] -- Provider.host_aggregate_metadata [0:]
892
925
  entity HostAggregateMetadata extends OpenStackResource: