inmanta-module-openstack 6.1.0__tar.gz → 6.1.1__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 (64) hide show
  1. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_module_openstack.egg-info/SOURCES.txt +1 -0
  4. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/common/base.py +11 -6
  5. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/compute/flavor.py +113 -3
  6. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/compute/virtual_machine.py +18 -11
  7. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/model/_init.cf +35 -2
  8. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/network_port.py +56 -1
  9. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/setup.cfg +1 -1
  10. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/setup.cfg +1 -1
  11. inmanta_module_openstack-6.1.1/tests/test_common_base.py +60 -0
  12. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_flavor.py +329 -0
  13. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_network_port_missing_network.py +1 -0
  14. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_network_port_shared_network.py +1 -0
  15. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_neutron.py +12 -5
  16. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_nova.py +25 -0
  17. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_server_group.py +1 -0
  18. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_trunk.py +2 -1
  19. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_vm_boot_nic.py +1 -0
  20. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/MANIFEST.in +0 -0
  21. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/README.md +0 -0
  22. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  23. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  24. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  25. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  26. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/__init__.py +0 -0
  27. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/common/__init__.py +0 -0
  28. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/common/deps.py +0 -0
  29. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  30. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/compute/host_aggregate.py +0 -0
  31. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/compute/server_group.py +0 -0
  32. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  33. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/group.py +0 -0
  34. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  35. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  36. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/project.py +0 -0
  37. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/role.py +0 -0
  38. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/identity/user.py +0 -0
  39. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/image/__init__.py +0 -0
  40. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/image/image.py +0 -0
  41. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/__init__.py +0 -0
  42. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  43. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/network.py +0 -0
  44. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  45. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/router.py +0 -0
  46. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/router_port.py +0 -0
  47. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/security_group.py +0 -0
  48. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/subnet.py +0 -0
  49. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  50. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  51. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  52. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  53. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/inmanta_plugins/openstack/quota/quota.py +0 -0
  54. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/pyproject.toml +0 -0
  55. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_block_devices.py +0 -0
  56. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_dependency_handling.py +0 -0
  57. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_examples.py +0 -0
  58. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_host_aggregate.py +0 -0
  59. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_image.py +0 -0
  60. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_keystone.py +0 -0
  61. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_network_port_fixed_ips.py +0 -0
  62. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_network_port_security_groups.py +0 -0
  63. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_quota.py +0 -0
  64. {inmanta_module_openstack-6.1.0 → inmanta_module_openstack-6.1.1}/tests/test_security_group_ethertype.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.1
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.1
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -43,6 +43,7 @@ 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
@@ -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,14 +346,15 @@ 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
 
@@ -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,6 @@ Contact: code@inmanta.com
17
17
  """
18
18
 
19
19
  import time
20
- import traceback
21
20
  from typing import Any
22
21
 
23
22
  from inmanta import ast
@@ -193,15 +192,17 @@ class VirtualMachine(OpenstackResource):
193
192
  class VirtualMachineHandler(OpenStackHandler):
194
193
  @cache(timeout=1, cache_none=False)
195
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
+
196
198
  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(),
199
+ server = self.get_server_by_name(resource.name, project_id, all_projects=True)
200
+ if server is None:
201
+ ctx.debug(
202
+ "No server named %(name)s found in project %(project)s.",
203
+ name=resource.name,
204
+ project=resource.project,
203
205
  )
204
- return None
205
206
  return server
206
207
 
207
208
  @cache(timeout=10, cache_none=False)
@@ -299,8 +300,13 @@ class VirtualMachineHandler(OpenStackHandler):
299
300
  ctx.info(f"Created a new keypair with name {resource.key_name}")
300
301
 
301
302
  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)
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
+ )
304
310
  return server is not None
305
311
 
306
312
  def _raise_server_error(self, ctx: HandlerContext, log_message: str) -> None:
@@ -441,7 +447,8 @@ class VirtualMachineHandler(OpenStackHandler):
441
447
  if self.server is None:
442
448
  raise ResourcePurged()
443
449
 
444
- if self.server.status == "ERROR":
450
+ # Raise error always except when the resource is being purged.
451
+ if self.server.status == "ERROR" and not resource.purged:
445
452
  self._raise_server_error(
446
453
  ctx, "Server is in ERROR state; delete it to allow redeployment"
447
454
  )
@@ -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:
@@ -224,7 +224,12 @@ 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
- vm = self.get_server_by_name(resource.host, project_id)
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
+ )
228
233
  if vm is None:
229
234
  return None
230
235
 
@@ -322,6 +327,39 @@ class NetworkPortHandler(OpenStackHandler):
322
327
  resource.name = self.port.name
323
328
  self._set_facts(ctx)
324
329
 
330
+ def calculate_diff(
331
+ self, ctx: handler.HandlerContext, current: NetworkPort, desired: NetworkPort
332
+ ) -> dict[str, dict[str, object]]:
333
+ changes = super().calculate_diff(ctx, current, desired)
334
+
335
+ pairs = changes.get("allowed_address_pairs")
336
+ if pairs is not None and self._equivalent_address_pairs(
337
+ current=pairs["current"], desired=pairs["desired"]
338
+ ):
339
+ del changes["allowed_address_pairs"]
340
+
341
+ return changes
342
+
343
+ @staticmethod
344
+ def _equivalent_address_pairs(
345
+ *, current: dict[str, Optional[str]], desired: dict[str, Optional[str]]
346
+ ) -> bool:
347
+ """
348
+ Whether two sets of allowed address pairs describe the same desired state.
349
+
350
+ Neutron pins the port's own MAC on a pair that does not carry one, so a pair
351
+ left without a MAC always reads back with one: the MAC of such a pair is not
352
+ managed and any value it holds is equivalent. A pair that does request a MAC
353
+ is compared as is.
354
+ """
355
+ if current.keys() != desired.keys():
356
+ return False
357
+
358
+ return all(
359
+ desired_mac is None or desired_mac == current[address]
360
+ for address, desired_mac in desired.items()
361
+ )
362
+
325
363
  def create_resource(
326
364
  self, ctx: handler.HandlerContext, resource: NetworkPort
327
365
  ) -> None:
@@ -455,6 +493,23 @@ class NetworkPortHandler(OpenStackHandler):
455
493
  changes: dict,
456
494
  resource: NetworkPort,
457
495
  ) -> None:
496
+ if "mac_address" in changes:
497
+ # Neutron only accepts a MAC address when it creates the port, so a port
498
+ # that is already there with another one cannot be reconciled - it has to be
499
+ # recreated. A port created before its MAC was pinned in the model looks
500
+ # exactly like this. Fail with the reason rather than through the generic
501
+ # unexpected-change check below, and never let the change through: nothing
502
+ # here can apply it, so the port would report an update on every deploy
503
+ # while staying on its original MAC.
504
+ raise ValueError(
505
+ f"Port {resource.name} has MAC address "
506
+ f"{changes['mac_address']['current']} but the model requests "
507
+ f"{changes['mac_address']['desired']}. Neutron assigns a MAC address "
508
+ "when the port is created and does not allow changing it afterwards, "
509
+ "so this port has to be deleted and recreated to take the requested "
510
+ "one."
511
+ )
512
+
458
513
  self.validate_allowed_fields(
459
514
  changes,
460
515
  {
@@ -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.0
25
+ version = 6.1.1
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.0
25
+ version = 6.1.1
26
26
  license = Apache 2.0
27
27
 
28
28
  [egg_info]
@@ -0,0 +1,60 @@
1
+ """
2
+ Copyright 2026 Inmanta
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: code@inmanta.com
17
+ """
18
+
19
+ from types import SimpleNamespace
20
+ from unittest.mock import MagicMock
21
+
22
+ from inmanta_plugins.openstack.common.base import OpenStackHandler
23
+
24
+
25
+ def _handler(connection) -> OpenStackHandler:
26
+ handler = object.__new__(OpenStackHandler)
27
+ handler._connection = connection
28
+ return handler
29
+
30
+
31
+ def test_get_project_id_uses_scoped_project_without_identity_lookup() -> None:
32
+ connection = SimpleNamespace(
33
+ current_project_id="project-id",
34
+ identity=MagicMock(),
35
+ )
36
+ resource = SimpleNamespace(admin_project_name="tenant-project")
37
+
38
+ assert (
39
+ _handler(connection).get_project_id(resource, "tenant-project") == "project-id"
40
+ )
41
+ connection.identity.find_project.assert_not_called()
42
+ connection.identity.get_project.assert_not_called()
43
+
44
+
45
+ def test_get_project_id_can_still_resolve_another_visible_project() -> None:
46
+ connection = SimpleNamespace(
47
+ current_project_id="current-project-id",
48
+ identity=MagicMock(),
49
+ )
50
+ connection.identity.find_project.return_value = SimpleNamespace(
51
+ id="other-project-id"
52
+ )
53
+ resource = SimpleNamespace(admin_project_name="current-project")
54
+
55
+ assert (
56
+ _handler(connection).get_project_id(resource, "other-project")
57
+ == "other-project-id"
58
+ )
59
+ connection.identity.find_project.assert_called_once_with("other-project")
60
+ connection.identity.get_project.assert_not_called()
@@ -18,6 +18,7 @@ Contact: code@inmanta.com
18
18
 
19
19
  import json
20
20
  import os
21
+ import uuid
21
22
 
22
23
  import inmanta
23
24
  import pytest
@@ -56,6 +57,59 @@ def cleanup(os_tester):
56
57
  cleanup_flavor(os_tester.connection)
57
58
 
58
59
 
60
+ @pytest.fixture()
61
+ def flavor_access_projects(os_tester):
62
+ project_domain_name = os.environ["OS_PROJECT_DOMAIN_NAME"]
63
+ projects = (
64
+ os_tester.get_project(f"flavor-access-1-{uuid.uuid4().hex[:8]}"),
65
+ os_tester.get_project(f"flavor-access-2-{uuid.uuid4().hex[:8]}"),
66
+ )
67
+
68
+ cleanup_flavor(os_tester.connection)
69
+
70
+ yield project_domain_name, projects
71
+
72
+ cleanup_flavor(os_tester.connection)
73
+
74
+
75
+ @pytest.fixture()
76
+ def flavor_access_member_user(os_tester, flavor_access_projects):
77
+ _, os_projects = flavor_access_projects
78
+ identity = os_tester.connection.identity
79
+ user_domain_name = os.environ["OS_USER_DOMAIN_NAME"]
80
+ user_domain = identity.find_domain(user_domain_name, ignore_missing=True)
81
+ if user_domain is None:
82
+ pytest.skip(f"The user domain {user_domain_name} does not exist.")
83
+
84
+ role = identity.find_role("member", ignore_missing=True)
85
+ if role is None:
86
+ pytest.skip("The member role does not exist.")
87
+
88
+ user_name = f"test-flavor-access-{uuid.uuid4().hex[:8]}"
89
+ password = f"test-flavor-access-{uuid.uuid4().hex}"
90
+ user = identity.create_user(
91
+ name=user_name,
92
+ password=password,
93
+ is_enabled=True,
94
+ domain_id=user_domain.id,
95
+ )
96
+
97
+ # Use a non-admin tenant user so admin policy does not bypass flavor access.
98
+ for os_project in os_projects:
99
+ identity.assign_project_role_to_user(os_project.project_object, user, role)
100
+
101
+ yield user_name, password
102
+
103
+ for os_project in os_projects:
104
+ try:
105
+ identity.unassign_project_role_from_user(
106
+ os_project.project_object, user, role
107
+ )
108
+ except Exception:
109
+ pass
110
+ identity.delete_user(user, ignore_missing=True)
111
+
112
+
59
113
  def compile_flavor(
60
114
  os_tester,
61
115
  project,
@@ -328,3 +382,278 @@ flavor=openstack::Flavor(
328
382
  assert os_flavor.id == flavor_id
329
383
  assert os_flavor.description == "Set on an explicit-id flavor"
330
384
  assert os_flavor.extra_specs == {"hw:watchdog_action": "reset"}
385
+
386
+
387
+ def test_flavor_project_access(project, os_tester, flavor_access_projects):
388
+ project_domain_name, os_projects = flavor_access_projects
389
+ project_name_1, project_name_2 = (
390
+ os_project.project_object.name for os_project in os_projects
391
+ )
392
+
393
+ def compile_model(allowed_projects):
394
+ os_tester.compile_with_provider(f"""
395
+ project_1 = openstack::Project(
396
+ provider=provider,
397
+ name="{project_name_1}",
398
+ domain="{project_domain_name}",
399
+ description="",
400
+ enabled=true,
401
+ purged=false,
402
+ )
403
+
404
+ project_2 = openstack::Project(
405
+ provider=provider,
406
+ name="{project_name_2}",
407
+ domain="{project_domain_name}",
408
+ description="",
409
+ enabled=true,
410
+ purged=false,
411
+ )
412
+
413
+ flavor = openstack::Flavor(
414
+ provider=provider,
415
+ name="test-flavor",
416
+ ram=1024,
417
+ vcpus=4,
418
+ disk=10,
419
+ is_public=false,
420
+ allowed_projects={allowed_projects},
421
+ )
422
+ """)
423
+
424
+ def project_ids_for_flavor_access():
425
+ flavor = get_test_flavor(os_tester.connection)
426
+ compute = os_tester.connection.compute
427
+ if hasattr(compute, "get_flavor_access"):
428
+ accesses = compute.get_flavor_access(flavor)
429
+ else:
430
+ accesses = compute.flavor_accesses(flavor)
431
+
432
+ project_ids = set()
433
+ for access in accesses:
434
+ project_id = getattr(access, "tenant_id", None) or getattr(
435
+ access, "project_id", None
436
+ )
437
+ if project_id is None and hasattr(access, "get"):
438
+ project_id = access.get("tenant_id") or access.get("project_id")
439
+ project_ids.add(project_id)
440
+ return project_ids
441
+
442
+ def project_id(name):
443
+ return os_tester.connection.identity.find_project(name).id
444
+
445
+ compile_model("[project_1, project_2]")
446
+ flavor = project.get_resource("openstack::Flavor", name="test-flavor")
447
+ assert flavor.allowed_projects == [
448
+ {"name": project_name_1, "domain": project_domain_name},
449
+ {"name": project_name_2, "domain": project_domain_name},
450
+ ]
451
+
452
+ for project_name in (project_name_1, project_name_2):
453
+ project_resource = project.get_resource("openstack::Project", name=project_name)
454
+ assert (
455
+ project.deploy(project_resource).status
456
+ == inmanta.const.ResourceState.deployed
457
+ )
458
+
459
+ assert project.deploy(flavor).status == inmanta.const.ResourceState.deployed
460
+ assert project_ids_for_flavor_access() == {
461
+ project_id(project_name_1),
462
+ project_id(project_name_2),
463
+ }
464
+
465
+ flavor_id = get_test_flavor(os_tester.connection).id
466
+ compile_model("[project_1]")
467
+ flavor = project.get_resource("openstack::Flavor", name="test-flavor")
468
+ assert project.deploy(flavor).status == inmanta.const.ResourceState.deployed
469
+ assert get_test_flavor(os_tester.connection).id == flavor_id
470
+ assert project_ids_for_flavor_access() == {project_id(project_name_1)}
471
+ assert not project.dryrun(flavor).changes
472
+
473
+
474
+ def test_private_flavor_controls_vm_creation(
475
+ project,
476
+ os_tester,
477
+ flavor_access_projects,
478
+ flavor_access_member_user,
479
+ ssh_public_key,
480
+ ):
481
+ project_domain_name, os_projects = flavor_access_projects
482
+ allowed_project, denied_project = os_projects
483
+ allowed_project_name = allowed_project.project_object.name
484
+ denied_project_name = denied_project.project_object.name
485
+ flavor_name = "test-flavor"
486
+ tenant_username, tenant_password = flavor_access_member_user
487
+
488
+ def compile_flavor_model():
489
+ os_tester.compile_with_provider(
490
+ f"""
491
+ project_1 = openstack::Project(
492
+ provider=provider,
493
+ name="{allowed_project_name}",
494
+ domain="{project_domain_name}",
495
+ description="",
496
+ enabled=true,
497
+ purged=false,
498
+ )
499
+
500
+ project_2 = openstack::Project(
501
+ provider=provider,
502
+ name="{denied_project_name}",
503
+ domain="{project_domain_name}",
504
+ description="",
505
+ enabled=true,
506
+ purged=false,
507
+ )
508
+
509
+ flavor = openstack::Flavor(
510
+ provider=provider,
511
+ name="{flavor_name}",
512
+ ram=1024,
513
+ vcpus=1,
514
+ disk=1,
515
+ is_public=false,
516
+ allowed_projects=[project_1],
517
+ )
518
+ """,
519
+ name="flavor_access_admin",
520
+ )
521
+
522
+ def vm_names(os_project):
523
+ return {
524
+ "key": os_project.get_resource_name("flavor-access-key"),
525
+ "network": os_project.get_resource_name("flavor-access-net"),
526
+ "port": os_project.get_resource_name("flavor-access-port"),
527
+ "server": (
528
+ os_project.get_resource_name("flavor-access-server").replace("_", "-")
529
+ ),
530
+ }
531
+
532
+ def compile_vm_model(os_project, names, provider_name: str, purged: bool = False):
533
+ purged_value = str(purged).lower()
534
+ os_tester.compile_with_provider(
535
+ f"""
536
+ import unittest
537
+ import ssh
538
+
539
+ tenant_project = openstack::Project(
540
+ provider=provider,
541
+ name=provider.project_name,
542
+ domain="{project_domain_name}",
543
+ description="",
544
+ enabled=true,
545
+ managed=false,
546
+ )
547
+ n = openstack::Network(
548
+ provider=provider,
549
+ name="{names["network"]}",
550
+ project=tenant_project,
551
+ purged={purged_value},
552
+ )
553
+ subnet = openstack::Subnet(
554
+ provider=provider,
555
+ project=tenant_project,
556
+ network=n,
557
+ dhcp=true,
558
+ name="{names["network"]}",
559
+ network_address="10.255.255.0/24",
560
+ purged={purged_value},
561
+ )
562
+
563
+ os = std::OS(name="cirros", version=0.4, family=std::linux)
564
+ key = ssh::Key(name="{names["key"]}", public_key="{ssh_public_key}")
565
+ vm = openstack::VirtualMachine(
566
+ provider=provider,
567
+ project=tenant_project,
568
+ key_pair=key,
569
+ name="{names["server"]}",
570
+ image=openstack::find_image(provider, os),
571
+ flavor="{flavor_name}",
572
+ user_data="",
573
+ purged={purged_value},
574
+ )
575
+ port = openstack::NetworkPort(
576
+ provider=provider,
577
+ project=tenant_project,
578
+ name="{names["port"]}",
579
+ subnet=subnet,
580
+ address="10.255.255.10",
581
+ dhcp=false,
582
+ vm=vm,
583
+ retries=1,
584
+ wait=0,
585
+ purged={purged_value},
586
+ )
587
+ vm.eth0_port = port
588
+ """,
589
+ name=provider_name,
590
+ username=tenant_username,
591
+ password=tenant_password,
592
+ project_name=os_project.project_object.name,
593
+ )
594
+
595
+ def deploy(resource_type: str, resource_name: str) -> None:
596
+ project.deploy_resource(resource_type, name=resource_name)
597
+
598
+ allowed_names = vm_names(allowed_project)
599
+ denied_names = vm_names(denied_project)
600
+
601
+ compile_flavor_model()
602
+ for project_name in (allowed_project_name, denied_project_name):
603
+ deploy("openstack::Project", project_name)
604
+ deploy("openstack::Flavor", flavor_name)
605
+
606
+ compile_vm_model(
607
+ denied_project,
608
+ denied_names,
609
+ # The find_image plugin caches by provider name, so each scope gets its own.
610
+ provider_name="flavor_access_denied",
611
+ )
612
+ deploy("openstack::Network", denied_names["network"])
613
+ deploy("openstack::Subnet", denied_names["network"])
614
+ project.deploy_resource(
615
+ "openstack::VirtualMachine",
616
+ name=denied_names["server"],
617
+ status=inmanta.const.ResourceState.failed,
618
+ )
619
+ assert (
620
+ denied_project.connection.compute.find_server(
621
+ denied_names["server"], ignore_missing=True
622
+ )
623
+ is None
624
+ )
625
+
626
+ compile_vm_model(
627
+ allowed_project,
628
+ allowed_names,
629
+ provider_name="flavor_access_allowed",
630
+ )
631
+ deploy("openstack::Network", allowed_names["network"])
632
+ deploy("openstack::Subnet", allowed_names["network"])
633
+ deploy("openstack::VirtualMachine", allowed_names["server"])
634
+ assert (
635
+ allowed_project.connection.compute.find_server(
636
+ allowed_names["server"], ignore_missing=True
637
+ )
638
+ is not None
639
+ )
640
+
641
+ compile_vm_model(
642
+ allowed_project,
643
+ allowed_names,
644
+ provider_name="flavor_access_allowed",
645
+ purged=True,
646
+ )
647
+ deploy("openstack::VirtualMachine", allowed_names["server"])
648
+ deploy("openstack::Subnet", allowed_names["network"])
649
+ deploy("openstack::Network", allowed_names["network"])
650
+
651
+ compile_vm_model(
652
+ denied_project,
653
+ denied_names,
654
+ provider_name="flavor_access_denied",
655
+ purged=True,
656
+ )
657
+ deploy("openstack::VirtualMachine", denied_names["server"])
658
+ deploy("openstack::Subnet", denied_names["network"])
659
+ deploy("openstack::Network", denied_names["network"])
@@ -73,6 +73,7 @@ def _port_handler(
73
73
 
74
74
  port_handler = typing.cast(NetworkPortHandler, project.get_handler(resource, False))
75
75
  port_handler._connection = MagicMock()
76
+ port_handler._connection.current_project_id = "project-id"
76
77
  port_handler._connection.identity.find_project.return_value = SimpleNamespace(
77
78
  id="project-id"
78
79
  )
@@ -88,6 +88,7 @@ def _port_handler(
88
88
 
89
89
  port_handler = typing.cast(NetworkPortHandler, project.get_handler(resource, False))
90
90
  port_handler._connection = MagicMock()
91
+ port_handler._connection.current_project_id = PROJECT_ID
91
92
  port_handler._connection.identity.find_project.return_value = SimpleNamespace(
92
93
  id=PROJECT_ID
93
94
  )
@@ -825,7 +825,9 @@ port = openstack::NetworkPort(
825
825
  assert ports[0].network_id == networks[0].id
826
826
 
827
827
 
828
- def test_shared_network(project: Project, os_tester: OpenstackTester) -> None:
828
+ def test_shared_network(
829
+ project: Project, os_tester: OpenstackTester, ssh_public_key: str
830
+ ) -> None:
829
831
  """
830
832
  Create a shared network as one project and add ports to it as another project
831
833
  """
@@ -866,7 +868,7 @@ def test_shared_network(project: Project, os_tester: OpenstackTester) -> None:
866
868
  network_address="10.255.254.0/24", dns_servers=["8.8.8.8", "8.8.4.4"], managed=true)
867
869
 
868
870
  os = std::OS(name="cirros", version=0.4, family=std::linux)
869
- key = ssh::Key(name="%(key_name)s", public_key="")
871
+ key = ssh::Key(name="%(key_name)s", public_key="%(ssh_public_key)s")
870
872
  vm = openstack::Host(provider=provider, project=project, key_pair=key, name="%(server_name)s", os=os,
871
873
  ip=std::getfact(vm.vm, "ip_address"),
872
874
  image=openstack::find_image(provider, os), flavor=openstack::find_flavor(provider, 1, 0.5),
@@ -877,6 +879,7 @@ def test_shared_network(project: Project, os_tester: OpenstackTester) -> None:
877
879
  % {
878
880
  "net_name": net_name,
879
881
  "key_name": key_name,
882
+ "ssh_public_key": ssh_public_key,
880
883
  "server_name": server_name,
881
884
  },
882
885
  project_name=project2._project_name,
@@ -908,7 +911,7 @@ def test_shared_network(project: Project, os_tester: OpenstackTester) -> None:
908
911
  network_address="10.255.253.0/24", dns_servers=["8.8.8.8", "8.8.4.4"], managed=true)
909
912
 
910
913
  os = std::OS(name="cirros", version=0.4, family=std::linux)
911
- key = ssh::Key(name="%(key_name)s", public_key="")
914
+ key = ssh::Key(name="%(key_name)s", public_key="%(ssh_public_key)s")
912
915
  vm = openstack::Host(provider=provider, project=project, key_pair=key, name="%(server_name)s", os=os,
913
916
  ip=std::getfact(vm.vm, "ip_address"),
914
917
  image=openstack::find_image(provider, os), flavor=openstack::find_flavor(provider, 1, 0.5),
@@ -923,6 +926,7 @@ def test_shared_network(project: Project, os_tester: OpenstackTester) -> None:
923
926
  % {
924
927
  "net_name": net_name,
925
928
  "key_name": key_name,
929
+ "ssh_public_key": ssh_public_key,
926
930
  "server_name": server_name,
927
931
  },
928
932
  project_name=project2._project_name,
@@ -933,7 +937,9 @@ def test_shared_network(project: Project, os_tester: OpenstackTester) -> None:
933
937
  project.deploy_resource("openstack::NetworkPort", name=server_name + "_eth2")
934
938
 
935
939
 
936
- def test_allowed_addr_port_with_trunk(project, os_tester, require_network_extension):
940
+ def test_allowed_addr_port_with_trunk(
941
+ project, os_tester, require_network_extension, ssh_public_key
942
+ ):
937
943
  """
938
944
  Test creating a port with allowed address pairs and trunk network/subports
939
945
  """
@@ -957,7 +963,7 @@ def test_allowed_addr_port_with_trunk(project, os_tester, require_network_extens
957
963
  network_address="10.255.255.0/24", dns_servers=["8.8.8.8", "8.8.4.4"])
958
964
 
959
965
  os = std::OS(name="cirros", version=0.4, family=std::linux)
960
- key = ssh::Key(name="%(key_name)s", public_key="")
966
+ key = ssh::Key(name="%(key_name)s", public_key="%(ssh_public_key)s")
961
967
 
962
968
  vm = openstack::VirtualMachine(provider=provider, project=project, key_pair=key, name="%(server_name)s",
963
969
  image=openstack::find_image(provider, os), flavor=openstack::find_flavor(provider, 1, 0.5),
@@ -993,6 +999,7 @@ def test_allowed_addr_port_with_trunk(project, os_tester, require_network_extens
993
999
  "port_name_2": port_name_2,
994
1000
  "server_name": server_name,
995
1001
  "key_name": key_name,
1002
+ "ssh_public_key": ssh_public_key,
996
1003
  "trunk_name": trunk_name,
997
1004
  "subport_name": subport_name,
998
1005
  },
@@ -17,7 +17,9 @@ Contact: code@inmanta.com
17
17
  """
18
18
 
19
19
  import time
20
+ from types import SimpleNamespace
20
21
  from typing import Optional
22
+ from unittest.mock import Mock
21
23
 
22
24
  import inmanta
23
25
  import inmanta.const
@@ -26,6 +28,29 @@ from conftest import OpenstackTester
26
28
  from inmanta.ast import ExplicitPluginException
27
29
  from pytest_inmanta.plugin import Project
28
30
 
31
+ from inmanta_plugins.openstack.common.base import OpenStackHandler
32
+
33
+
34
+ def test_get_server_by_name_all_projects_is_opt_in() -> None:
35
+ handler = OpenStackHandler.__new__(OpenStackHandler)
36
+ server = SimpleNamespace(name="server")
37
+ servers = Mock(return_value=iter([server]))
38
+ handler._connection = SimpleNamespace(compute=SimpleNamespace(servers=servers))
39
+
40
+ assert handler.get_server_by_name("server", project_id="project-id") is server
41
+ servers.assert_called_once_with(name="^server$", project_id="project-id")
42
+
43
+ servers.reset_mock()
44
+ servers.return_value = iter([server])
45
+
46
+ assert (
47
+ handler.get_server_by_name("server", project_id="project-id", all_projects=True)
48
+ is server
49
+ )
50
+ servers.assert_called_once_with(
51
+ name="^server$", project_id="project-id", all_projects=True
52
+ )
53
+
29
54
 
30
55
  def get_model(
31
56
  name: str,
@@ -203,6 +203,7 @@ def _vm_handler(project):
203
203
  VirtualMachineHandler, project.get_handler(resource, False)
204
204
  )
205
205
  vm_handler._connection = MagicMock()
206
+ vm_handler._connection.current_project_id = "project-id"
206
207
  vm_handler._connection.identity.find_project.return_value = SimpleNamespace(
207
208
  id="project-id"
208
209
  )
@@ -296,6 +296,7 @@ def test_trunk_deploy_on_vm_parent_port(
296
296
  project: pytest_inmanta.plugin.Project,
297
297
  os_tester,
298
298
  require_network_extension,
299
+ ssh_public_key,
299
300
  ):
300
301
  """
301
302
  Deploy a trunk whose parent port is attached to a VM.
@@ -327,7 +328,7 @@ def test_trunk_deploy_on_vm_parent_port(
327
328
  network_address="10.234.0.0/24", dns_servers=["8.8.8.8", "8.8.4.4"])
328
329
 
329
330
  os = std::OS(name="cirros", version=0.4, family=std::linux)
330
- key = ssh::Key(name="{key_name}", public_key="")
331
+ key = ssh::Key(name="{key_name}", public_key="{ssh_public_key}")
331
332
 
332
333
  vm = openstack::VirtualMachine(provider=provider, project=project, key_pair=key, name="{server_name}",
333
334
  image=openstack::find_image(provider, os),
@@ -157,6 +157,7 @@ def _vm_handler(
157
157
  VirtualMachineHandler, project.get_handler(resource, False)
158
158
  )
159
159
  vm_handler._connection = MagicMock()
160
+ vm_handler._connection.current_project_id = PROJECT_ID
160
161
  vm_handler._connection.identity.find_project.return_value = SimpleNamespace(
161
162
  id=PROJECT_ID
162
163
  )