inmanta-module-openstack 6.1.1__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.1 → inmanta_module_openstack-6.1.2}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/SOURCES.txt +6 -3
  4. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/common/base.py +10 -0
  5. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/virtual_machine.py +115 -70
  6. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/network_port.py +1 -6
  7. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/router.py +15 -5
  8. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/setup.cfg +1 -1
  9. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/setup.cfg +1 -1
  10. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_block_devices.py +1 -1
  11. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_common_base.py +6 -0
  12. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_host_aggregate.py +3 -3
  13. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_image.py +5 -31
  14. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_keystone.py +1 -1
  15. inmanta_module_openstack-6.1.2/tests/test_network.py +366 -0
  16. inmanta_module_openstack-6.1.2/tests/test_network_port.py +684 -0
  17. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_network_port_fixed_ips.py +1 -1
  18. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_network_port_security_groups.py +1 -1
  19. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_nova.py +12 -7
  20. inmanta_module_openstack-6.1.2/tests/test_qos_policy.py +156 -0
  21. inmanta_module_openstack-6.1.2/tests/test_router.py +263 -0
  22. inmanta_module_openstack-6.1.2/tests/test_security_group.py +406 -0
  23. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_security_group_ethertype.py +2 -2
  24. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_server_group.py +114 -89
  25. inmanta_module_openstack-6.1.2/tests/test_subnet.py +378 -0
  26. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_trunk.py +1 -1
  27. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_vm_boot_nic.py +127 -97
  28. inmanta_module_openstack-6.1.1/tests/test_network_port_missing_network.py +0 -177
  29. inmanta_module_openstack-6.1.1/tests/test_network_port_shared_network.py +0 -264
  30. inmanta_module_openstack-6.1.1/tests/test_neutron.py +0 -1640
  31. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/MANIFEST.in +0 -0
  32. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/README.md +0 -0
  33. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  34. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  35. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  36. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  37. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/__init__.py +0 -0
  38. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/common/__init__.py +0 -0
  39. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/common/deps.py +0 -0
  40. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  41. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/flavor.py +0 -0
  42. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/host_aggregate.py +0 -0
  43. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/compute/server_group.py +0 -0
  44. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  45. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/group.py +0 -0
  46. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  47. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  48. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/project.py +0 -0
  49. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/role.py +0 -0
  50. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/identity/user.py +0 -0
  51. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/image/__init__.py +0 -0
  52. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/image/image.py +0 -0
  53. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/model/_init.cf +0 -0
  54. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/__init__.py +0 -0
  55. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  56. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/network.py +0 -0
  57. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  58. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/router_port.py +0 -0
  59. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/security_group.py +0 -0
  60. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/subnet.py +0 -0
  61. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  62. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  63. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  64. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  65. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/inmanta_plugins/openstack/quota/quota.py +0 -0
  66. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/pyproject.toml +0 -0
  67. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_dependency_handling.py +0 -0
  68. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_examples.py +0 -0
  69. {inmanta_module_openstack-6.1.1 → inmanta_module_openstack-6.1.2}/tests/test_flavor.py +0 -0
  70. {inmanta_module_openstack-6.1.1 → 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.1
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.1
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
@@ -50,14 +50,17 @@ tests/test_flavor.py
50
50
  tests/test_host_aggregate.py
51
51
  tests/test_image.py
52
52
  tests/test_keystone.py
53
+ tests/test_network.py
54
+ tests/test_network_port.py
53
55
  tests/test_network_port_fixed_ips.py
54
- tests/test_network_port_missing_network.py
55
56
  tests/test_network_port_security_groups.py
56
- tests/test_network_port_shared_network.py
57
- tests/test_neutron.py
58
57
  tests/test_nova.py
58
+ tests/test_qos_policy.py
59
59
  tests/test_quota.py
60
+ tests/test_router.py
61
+ tests/test_security_group.py
60
62
  tests/test_security_group_ethertype.py
61
63
  tests/test_server_group.py
64
+ tests/test_subnet.py
62
65
  tests/test_trunk.py
63
66
  tests/test_vm_boot_nic.py
@@ -360,6 +360,16 @@ class OpenStackHandler(CRUDHandler):
360
360
 
361
361
  return self.require_single_item(servers)
362
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
+
363
373
  def _get_qos_policy(self, **query_params):
364
374
  try:
365
375
  policies = self._connection.network.qos_policies(**query_params)
@@ -17,6 +17,7 @@ Contact: code@inmanta.com
17
17
  """
18
18
 
19
19
  import time
20
+ from collections.abc import Callable
20
21
  from typing import Any
21
22
 
22
23
  from inmanta import ast
@@ -29,7 +30,7 @@ from inmanta.agent.handler import (
29
30
  )
30
31
  from inmanta.execute import proxy
31
32
  from inmanta.resources import resource
32
- from openstack.exceptions import NotFoundException, ResourceFailure, ResourceTimeout
33
+ from openstack.exceptions import NotFoundException
33
34
 
34
35
  from inmanta_plugins.openstack.common.base import (
35
36
  NotUniqueException,
@@ -190,13 +191,9 @@ class VirtualMachine(OpenstackResource):
190
191
 
191
192
  @provider("openstack::VirtualMachine", name="openstack")
192
193
  class VirtualMachineHandler(OpenStackHandler):
193
- @cache(timeout=1, cache_none=False)
194
194
  def _get_server(self, ctx, resource):
195
- if resource.project == resource.admin_project_name:
196
- return self.get_server_by_name(resource.name)
197
-
198
195
  project_id = self.get_project_id(resource, resource.project)
199
- server = self.get_server_by_name(resource.name, project_id, all_projects=True)
196
+ server = self.get_project_server(resource, resource.name, project_id)
200
197
  if server is None:
201
198
  ctx.debug(
202
199
  "No server named %(name)s found in project %(project)s.",
@@ -299,26 +296,18 @@ class VirtualMachineHandler(OpenStackHandler):
299
296
  self._connection.compute.create_keypair(**keypair_args)
300
297
  ctx.info(f"Created a new keypair with name {resource.key_name}")
301
298
 
302
- def _server_with_name_exists(self, resource):
303
- if resource.project == resource.admin_project_name:
304
- server = self.get_server_by_name(resource.name)
305
- else:
306
- project_id = self.get_project_id(resource, resource.project)
307
- server = self.get_server_by_name(
308
- resource.name, project_id, all_projects=True
309
- )
310
- 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))
311
302
 
312
- def _raise_server_error(self, ctx: HandlerContext, log_message: str) -> None:
313
- """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.
314
307
 
315
- Used both when a server is found in ERROR state by read_resource and
316
- when it transitions to ERROR during a build. Re-fetching is necessary
317
- because the list API used by get_server_by_name may omit the fault body.
318
308
  The ERROR-state server must be deleted (manually or via purge) before
319
309
  Inmanta will attempt to recreate it.
320
310
  """
321
- server = self._connection.compute.get_server(self.server.id)
322
311
  fault = getattr(server, "fault", None) or {}
323
312
  msg = fault.get("message", "unknown error")
324
313
  details = fault.get("details", "")
@@ -326,12 +315,42 @@ class VirtualMachineHandler(OpenStackHandler):
326
315
  log_message,
327
316
  fault_message=msg,
328
317
  fault_details=details,
329
- server_id=self.server.id,
318
+ server_id=server.id,
330
319
  )
331
320
  raise Exception(
332
- 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}"
333
322
  ) from None
334
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
+
335
354
  def _wait_for_status(
336
355
  self,
337
356
  ctx: HandlerContext,
@@ -345,40 +364,31 @@ class VirtualMachineHandler(OpenStackHandler):
345
364
  and is only used to build the error messages.
346
365
 
347
366
  Nova may briefly return a 404 for the instance while a resize is being
348
- applied; such transient NotFoundException errors are tolerated and polling
349
- resumes until the status is reached or the timeout elapses.
367
+ applied, this is treated as not ready yet.
350
368
  """
351
- deadline = time.monotonic() + timeout
352
- while True:
353
- remaining = deadline - time.monotonic()
354
- if remaining <= 0:
355
- self._raise_status_timeout(ctx, status, timeout)
356
- try:
357
- self.server = self._connection.compute.wait_for_server(
358
- self.server, status=status, wait=max(1, int(remaining))
359
- )
360
- return
361
- except ResourceFailure:
362
- self._raise_server_error(
363
- ctx, f"VM entered ERROR state during {operation}"
364
- )
365
- except ResourceTimeout:
366
- self._raise_status_timeout(ctx, status, timeout)
367
- except NotFoundException:
368
- # The instance is temporarily not retrievable (e.g. mid-resize);
369
- # give it a moment and keep polling until the deadline.
370
- 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)
371
379
 
372
380
  def _raise_status_timeout(
373
- self, ctx: HandlerContext, status: str, timeout: int
381
+ self, ctx: HandlerContext, status: str, operation: str, timeout: int
374
382
  ) -> None:
375
- try:
376
- server = self._connection.compute.get_server(self.server.id)
377
- current_status = getattr(server, "status", "unknown")
378
- task_state = getattr(server, "task_state", "unknown")
379
- 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:
380
387
  current_status = "not_found"
381
388
  task_state = "unknown"
389
+ else:
390
+ current_status = server.status
391
+ task_state = server.task_state
382
392
  ctx.error(
383
393
  f"Timed out waiting for VM to reach {status}",
384
394
  timeout_seconds=timeout,
@@ -391,6 +401,27 @@ class VirtualMachineHandler(OpenStackHandler):
391
401
  f"(status={current_status}, task_state={task_state})"
392
402
  ) from None
393
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
+
394
425
  def _get_server_flavor_name(self, server) -> str:
395
426
  """Return the flavor name of `server`.
396
427
 
@@ -442,15 +473,24 @@ class VirtualMachineHandler(OpenStackHandler):
442
473
  """
443
474
  This method will check what the status of the give resource is on
444
475
  openstack.
476
+
477
+ A server in ERROR state raises, unless it is being deleted or purged.
445
478
  """
446
479
  self.server = self._get_server(ctx, resource)
447
480
  if self.server is None:
448
481
  raise ResourcePurged()
449
482
 
450
- # Raise error always except when the resource is being purged.
451
- if self.server.status == "ERROR" and not resource.purged:
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
+ ):
452
489
  self._raise_server_error(
453
- 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!",
454
494
  )
455
495
 
456
496
  resource.purged = False
@@ -504,6 +544,8 @@ class VirtualMachineHandler(OpenStackHandler):
504
544
  metadata=resource.metadata,
505
545
  files=resource.personality,
506
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.
507
549
  if resource.block_devices:
508
550
  args["block_device_mapping_v2"] = list(resource.block_devices)
509
551
  else:
@@ -534,32 +576,36 @@ class VirtualMachineHandler(OpenStackHandler):
534
576
 
535
577
  def delete_resource(self, ctx: HandlerContext, resource: VirtualMachine) -> None:
536
578
  self._connection.compute.delete_server(self.server, ignore_missing=True)
579
+ timeout = 120
537
580
 
538
- def is_server_deletion_finished() -> bool:
539
- ports = list(self._connection.network.ports(device_id=self.server.id))
540
- if ports:
541
- return False
542
- return self._get_server(ctx, resource) is None
581
+ def is_server_deleted() -> bool:
582
+ return self._poll_server(ctx, "delete") is None
543
583
 
544
- # Wait until the server has been deleted
545
- count = 0
546
- ctx.info("Waiting until server is deleted.")
547
- while not is_server_deletion_finished() and count < 60:
548
- time.sleep(1)
549
- 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()
550
588
 
551
- if count >= 60:
552
- 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)
553
594
 
595
+ ctx.info("Server was deleted.")
554
596
  ctx.set_purged()
555
597
 
556
598
  def update_resource(
557
599
  self, ctx: HandlerContext, changes: dict, resource: VirtualMachine
558
600
  ) -> None:
559
-
601
+ # read_resource lets through an ERROR server that is being deleted.
560
602
  if self.server.status == "ERROR":
561
603
  self._raise_server_error(
562
- 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!",
563
609
  )
564
610
 
565
611
  self.validate_allowed_fields(
@@ -587,7 +633,6 @@ class VirtualMachineHandler(OpenStackHandler):
587
633
  self.server, remove_rule
588
634
  )
589
635
 
590
- self.server = self._get_server(ctx, resource)
591
636
  ctx.set_updated()
592
637
 
593
638
  def _set_facts(self, ctx):
@@ -224,12 +224,7 @@ class NetworkPortHandler(OpenStackHandler):
224
224
  tries = 0
225
225
  max_attempts = resource.retries if resource.retries > 0 else 1
226
226
  while tries < max_attempts:
227
- cross_project = resource.project != resource.admin_project_name
228
- vm = self.get_server_by_name(
229
- resource.host,
230
- project_id if cross_project else None,
231
- all_projects=cross_project,
232
- )
227
+ vm = self.get_project_server(resource, resource.host, project_id)
233
228
  if vm is None:
234
229
  return None
235
230
 
@@ -20,6 +20,7 @@ from inmanta.agent import handler
20
20
  from inmanta.agent.handler import ResourcePurged, SkipResource, provider
21
21
  from inmanta.execute import proxy
22
22
  from inmanta.resources import resource
23
+ from openstack.network.v2.subnet import Subnet as OSSubnet
23
24
 
24
25
  from inmanta_plugins.openstack.common.base import (
25
26
  OpenStackHandler,
@@ -163,14 +164,24 @@ class RouterHandler(OpenStackHandler):
163
164
  self._connection.network.delete_router(self.router.id, ignore_missing=True)
164
165
  ctx.set_purged()
165
166
 
167
+ def _get_router_subnet(self, name: str) -> OSSubnet | None:
168
+ """
169
+ Retrieve a subnet of the router by name. A subnet name is only unique within a
170
+ project, so the router's own project is searched first. Only when it has no such
171
+ subnet, the subnet of another project is looked up, e.g. one of a shared network.
172
+ """
173
+ subnet = self.get_subnet(self.project_id, name=name)
174
+ if subnet is None:
175
+ subnet = self.get_subnet(name=name)
176
+ return subnet
177
+
166
178
  def _update_subnets(self, current, desired):
167
179
  current = set(current)
168
180
  to = set(desired)
169
181
 
170
182
  # subnets to add to the router
171
183
  for subnet_name in to - current:
172
- # query for the subnet id.
173
- subnet = self.get_subnet(name=subnet_name)
184
+ subnet = self._get_router_subnet(subnet_name)
174
185
  if subnet is None:
175
186
  raise Exception(f"Unable to find id of subnet {subnet_name}")
176
187
 
@@ -180,10 +191,9 @@ class RouterHandler(OpenStackHandler):
180
191
 
181
192
  # subnets to delete
182
193
  for subnet_name in current - to:
183
- # query for the subnet id.
184
- subnet = self.get_subnet(name=subnet_name)
194
+ subnet = self._get_router_subnet(subnet_name)
185
195
  if subnet is None:
186
- raise Exception("Unable to find id of subnet %s" % subnet)
196
+ raise Exception(f"Unable to find id of subnet {subnet_name}")
187
197
 
188
198
  self._connection.network.remove_interface_from_router(
189
199
  self.router, subnet_id=subnet.id
@@ -22,7 +22,7 @@ target-version = 'py36', 'py37', 'py38'
22
22
  name = inmanta-module-openstack
23
23
  freeze_recursive = False
24
24
  freeze_operator = ~=
25
- version = 6.1.1
25
+ version = 6.1.2
26
26
  license = Apache 2.0
27
27
 
28
28
  [egg_info]
@@ -22,7 +22,7 @@ target-version = 'py36', 'py37', 'py38'
22
22
  name = inmanta-module-openstack
23
23
  freeze_recursive = False
24
24
  freeze_operator = ~=
25
- version = 6.1.1
25
+ version = 6.1.2
26
26
  license = Apache 2.0
27
27
 
28
28
  [egg_info]
@@ -22,7 +22,7 @@ from typing import Optional
22
22
  import pytest
23
23
  from conftest import assert_dryrun_deploy_dryrun
24
24
 
25
- TEST_VM_NAME = "inmanta-unit-test-bdm"
25
+ TEST_VM_NAME = "os-unit-test-bdm"
26
26
 
27
27
 
28
28
  def get_bdm_model(
@@ -29,6 +29,11 @@ def _handler(connection) -> OpenStackHandler:
29
29
 
30
30
 
31
31
  def test_get_project_id_uses_scoped_project_without_identity_lookup() -> None:
32
+ """
33
+ A project-scoped token already knows its own project id, so it is used without a
34
+ Keystone lookup: a non-admin user may be forbidden to get or list projects. The
35
+ deployment tests run with admin credentials, which are never refused that lookup.
36
+ """
32
37
  connection = SimpleNamespace(
33
38
  current_project_id="project-id",
34
39
  identity=MagicMock(),
@@ -43,6 +48,7 @@ def test_get_project_id_uses_scoped_project_without_identity_lookup() -> None:
43
48
 
44
49
 
45
50
  def test_get_project_id_can_still_resolve_another_visible_project() -> None:
51
+ """Another project than the one the token is scoped to is still looked up."""
46
52
  connection = SimpleNamespace(
47
53
  current_project_id="current-project-id",
48
54
  identity=MagicMock(),
@@ -19,12 +19,12 @@ Contact: code@inmanta.com
19
19
  import inmanta
20
20
  import pytest
21
21
 
22
- AGGREGATE_NAME = "inmanta_unit_test_aggregate"
22
+ AGGREGATE_NAME = "os_unit_test_aggregate"
23
23
  # An unrelated key seeded on the aggregate that must survive every operation, proving
24
24
  # the resource only manages its own key and never clobbers others.
25
25
  OTHER_KEY = "filter_tenant_id_other"
26
26
  OTHER_VALUE = "other-project-uuid"
27
- MANAGED_KEY = "filter_tenant_id_inmanta_unit_test"
27
+ MANAGED_KEY = "filter_tenant_id_os_unit_test"
28
28
 
29
29
 
30
30
  def _find_aggregate(connection):
@@ -128,7 +128,7 @@ def test_host_aggregate_metadata_missing_aggregate_is_skipped(project, os_tester
128
128
  os_tester.compile_with_provider("""
129
129
  openstack::HostAggregateMetadata(
130
130
  provider=provider,
131
- aggregate="inmanta_unit_test_missing_aggregate",
131
+ aggregate="os_unit_test_missing_aggregate",
132
132
  key="filter_tenant_id_x",
133
133
  value="uuid-x",
134
134
  )
@@ -24,7 +24,7 @@ import inmanta
24
24
  import inmanta.agent.handler
25
25
  import pytest
26
26
 
27
- TEST_IMAGE_NAME = "inmanta_unit_test"
27
+ TEST_IMAGE_NAME = "os_unit_test"
28
28
  CIRROS_URI = "http://download.cirros-cloud.net/0.6.2/cirros-0.6.2-x86_64-disk.img"
29
29
 
30
30
 
@@ -424,6 +424,7 @@ image=openstack::Image(
424
424
 
425
425
  ctx_deploy = project.deploy(created_image)
426
426
  assert ctx_deploy.status == inmanta.const.ResourceState.deployed
427
+ assert not project.dryrun(created_image).changes
427
428
 
428
429
  matching_images = list(os_tester.connection.image.images(name=TEST_IMAGE_NAME))
429
430
  assert len(matching_images) == 1
@@ -436,7 +437,9 @@ def test_file_upload_without_glance_direct(tmp_path):
436
437
  Uploading a local file must not require the 'glance-direct' import method.
437
438
  When the cloud only enables 'web-download', the handler falls back to the
438
439
  classic direct data upload (PUT /v2/images/{id}/file) instead of raising.
439
- Regression test for clouds that do not enable glance-direct.
440
+ Regression test for clouds that do not enable glance-direct. The lab cloud does
441
+ enable it, so this cannot be a deployment test: test_create_image_from_file covers
442
+ the glance-direct upload.
440
443
  """
441
444
  from unittest.mock import MagicMock
442
445
 
@@ -459,32 +462,3 @@ def test_file_upload_without_glance_direct(tmp_path):
459
462
  handler.image.upload.assert_called_once()
460
463
  handler._connection.image.stage_image.assert_not_called()
461
464
  handler._connection.image.import_image.assert_not_called()
462
-
463
-
464
- def test_file_upload_with_glance_direct(tmp_path):
465
- """
466
- When the cloud enables 'glance-direct', a local file is staged and imported
467
- through the interoperable image import API rather than uploaded directly.
468
- """
469
- from unittest.mock import MagicMock
470
-
471
- from inmanta_plugins.openstack.image.image import ImageHandler
472
-
473
- image_file = tmp_path / "image.img"
474
- image_file.write_bytes(b"fake-image-data")
475
-
476
- handler = ImageHandler.__new__(ImageHandler)
477
- handler._connection = MagicMock()
478
- handler.image = MagicMock()
479
-
480
- resource = MagicMock()
481
- resource.file = str(image_file)
482
- resource.uri = None
483
-
484
- handler._import_image_via_methods(resource, {"web-download", "glance-direct"})
485
-
486
- handler._connection.image.stage_image.assert_called_once()
487
- handler._connection.image.import_image.assert_called_once_with(
488
- handler.image, method="glance-direct"
489
- )
490
- handler.image.upload.assert_not_called()
@@ -27,7 +27,7 @@ import pytest
27
27
  USERNAME = "test_user123"
28
28
  EMAIL = "test@example.com"
29
29
  PASSWORD = "password123"
30
- PROJECT_NAME = "inmanta_unit_test"
30
+ PROJECT_NAME = "os_unit_test"
31
31
  ROLE_MEMBER = "member"
32
32
  GROUP_NAME = "inmanta_test_group"
33
33
  GROUP_USER_NAME = "inmanta_group_test_user"