inmanta-module-openstack 5.0.2__tar.gz → 5.1.0__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 (57) hide show
  1. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_module_openstack.egg-info/SOURCES.txt +3 -0
  4. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/common/deps.py +37 -15
  5. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/compute/flavor.py +48 -38
  6. inmanta_module_openstack-5.1.0/inmanta_plugins/openstack/compute/host_aggregate.py +117 -0
  7. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/compute/host_port.py +33 -3
  8. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/compute/virtual_machine.py +15 -2
  9. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/model/_init.cf +43 -0
  10. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/network_port.py +1 -1
  11. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/setup.cfg +1 -1
  12. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/setup.cfg +1 -1
  13. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_flavor.py +68 -3
  14. inmanta_module_openstack-5.1.0/tests/test_host_aggregate.py +138 -0
  15. inmanta_module_openstack-5.1.0/tests/test_host_port_security_groups.py +441 -0
  16. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_neutron.py +81 -0
  17. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_nova.py +67 -2
  18. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/MANIFEST.in +0 -0
  19. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/README.md +0 -0
  20. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  21. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  22. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  23. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  24. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/__init__.py +0 -0
  25. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/common/__init__.py +0 -0
  26. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/common/base.py +0 -0
  27. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  28. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  29. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/group.py +0 -0
  30. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  31. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  32. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/project.py +0 -0
  33. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/role.py +0 -0
  34. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/identity/user.py +0 -0
  35. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/image/__init__.py +0 -0
  36. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/image/image.py +0 -0
  37. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/__init__.py +0 -0
  38. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  39. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/network.py +0 -0
  40. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  41. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/router.py +0 -0
  42. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/router_port.py +0 -0
  43. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/security_group.py +0 -0
  44. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/subnet.py +0 -0
  45. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  46. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  47. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  48. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  49. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/inmanta_plugins/openstack/quota/quota.py +0 -0
  50. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/pyproject.toml +0 -0
  51. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_block_devices.py +0 -0
  52. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_dependency_handling.py +0 -0
  53. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_examples.py +0 -0
  54. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_image.py +0 -0
  55. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_keystone.py +0 -0
  56. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_quota.py +0 -0
  57. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.0}/tests/test_trunk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inmanta-module-openstack
3
- Version: 5.0.2
3
+ Version: 5.1.0
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inmanta-module-openstack
3
- Version: 5.0.2
3
+ Version: 5.1.0
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -15,6 +15,7 @@ inmanta_plugins/openstack/common/base.py
15
15
  inmanta_plugins/openstack/common/deps.py
16
16
  inmanta_plugins/openstack/compute/__init__.py
17
17
  inmanta_plugins/openstack/compute/flavor.py
18
+ inmanta_plugins/openstack/compute/host_aggregate.py
18
19
  inmanta_plugins/openstack/compute/host_port.py
19
20
  inmanta_plugins/openstack/compute/virtual_machine.py
20
21
  inmanta_plugins/openstack/identity/__init__.py
@@ -45,6 +46,8 @@ tests/test_block_devices.py
45
46
  tests/test_dependency_handling.py
46
47
  tests/test_examples.py
47
48
  tests/test_flavor.py
49
+ tests/test_host_aggregate.py
50
+ tests/test_host_port_security_groups.py
48
51
  tests/test_image.py
49
52
  tests/test_keystone.py
50
53
  tests/test_neutron.py
@@ -17,11 +17,15 @@ Contact: code@inmanta.com
17
17
  """
18
18
 
19
19
  from collections import defaultdict
20
+ from collections.abc import Mapping
20
21
 
21
22
  from inmanta.export import dependency_manager
22
23
 
23
24
  from inmanta_plugins.openstack.common.base import OpenstackResource
24
25
 
26
+ # Per-provider view of the collected resources: resource type -> resource name -> resource.
27
+ ResourceCollector = Mapping[str, Mapping[str, OpenstackResource]]
28
+
25
29
  MANAGED_DEPENDENCIES = {
26
30
  "openstack::Project",
27
31
  "openstack::Network",
@@ -34,9 +38,15 @@ MANAGED_DEPENDENCIES = {
34
38
  }
35
39
 
36
40
 
37
- def resource_collector(resource_model):
38
- purged_resources = defaultdict(lambda: defaultdict(dict))
39
- non_purged_resources = defaultdict(lambda: defaultdict(dict))
41
+ def resource_collector(
42
+ resource_model: Mapping[object, OpenstackResource],
43
+ ) -> tuple[Mapping[object, ResourceCollector], Mapping[object, ResourceCollector]]:
44
+ purged_resources: dict[object, dict[str, dict[str, OpenstackResource]]] = (
45
+ defaultdict(lambda: defaultdict(dict))
46
+ )
47
+ non_purged_resources: dict[object, dict[str, dict[str, OpenstackResource]]] = (
48
+ defaultdict(lambda: defaultdict(dict))
49
+ )
40
50
 
41
51
  for current_resource in resource_model.values():
42
52
  rtype = current_resource.id.entity_type
@@ -51,14 +61,13 @@ def resource_collector(resource_model):
51
61
  return purged_resources, non_purged_resources
52
62
 
53
63
 
54
- def set_dependencies_on_purged_resources(
55
- collector: dict[str, OpenstackResource],
56
- ) -> None:
64
+ def set_dependencies_on_purged_resources(collector: ResourceCollector) -> None:
57
65
  """
58
- This method only sets the dependencies between a VM and its ports!
66
+ Set the dependencies between purged VMs/ports and the security groups they reference.
59
67
  """
60
68
  vms = collector.get("openstack::VirtualMachine", {})
61
69
  ports = collector.get("openstack::HostPort", {})
70
+ sgs = collector.get("openstack::SecurityGroup", {})
62
71
 
63
72
  # When the VM is purged, all its purged ports should be deployed first
64
73
  for vm in vms.values():
@@ -67,10 +76,15 @@ def set_dependencies_on_purged_resources(
67
76
  port_id = ports[vm_port["name"]].id
68
77
  vm.requires.add(port_id)
69
78
 
79
+ # When a security group is purged, every port referencing it must be removed (or have the
80
+ # reference dropped) before the group itself can be deleted.
81
+ for port in ports.values():
82
+ for sg_name in port.security_groups:
83
+ if sg_name in sgs:
84
+ sgs[sg_name].requires.add(port.id)
70
85
 
71
- def set_dependencies_on_non_purged_resources(
72
- collector: dict[str, OpenstackResource],
73
- ) -> None:
86
+
87
+ def set_dependencies_on_non_purged_resources(collector: ResourceCollector) -> None:
74
88
  projects = collector.get("openstack::Project", {})
75
89
  networks = collector.get("openstack::Network", {})
76
90
  routers = collector.get("openstack::Router", {})
@@ -131,6 +145,10 @@ def set_dependencies_on_non_purged_resources(
131
145
  if port.host in vms:
132
146
  port.requires.add(vms[port.host].id)
133
147
 
148
+ for sg_name in port.security_groups:
149
+ if sg_name in sgs:
150
+ port.requires.add(sgs[sg_name].id)
151
+
134
152
  for fip in fips.values():
135
153
  if fip.external_network in networks:
136
154
  fip.requires.add(networks[fip.external_network].id)
@@ -146,7 +164,9 @@ def set_dependencies_on_non_purged_resources(
146
164
 
147
165
 
148
166
  @dependency_manager
149
- def openstack_dependencies(config_model, resource_model):
167
+ def openstack_dependencies(
168
+ config_model: object, resource_model: Mapping[object, OpenstackResource]
169
+ ) -> None:
150
170
  purged_resources, non_purged_resources = resource_collector(resource_model)
151
171
  for project, collector in non_purged_resources.items():
152
172
  set_dependencies_on_non_purged_resources(collector)
@@ -155,10 +175,12 @@ def openstack_dependencies(config_model, resource_model):
155
175
 
156
176
 
157
177
  @dependency_manager
158
- def keystone_dependencies(config_model, resource_model):
159
- projects = {}
160
- users = {}
161
- roles = []
178
+ def keystone_dependencies(
179
+ config_model: object, resource_model: Mapping[object, OpenstackResource]
180
+ ) -> None:
181
+ projects: dict[str, OpenstackResource] = {}
182
+ users: dict[str, OpenstackResource] = {}
183
+ roles: list[OpenstackResource] = []
162
184
  for _, res in resource_model.items():
163
185
  if res.id.entity_type == "openstack::Project":
164
186
  if not res.purged:
@@ -79,6 +79,29 @@ class FlavorHandler(OpenStackHandler):
79
79
  )
80
80
  return self.require_single_item(flavors)
81
81
 
82
+ def _create_flavor(self, resource: Flavor):
83
+ """Create a flavor in OpenStack."""
84
+ flavor = self._connection.compute.create_flavor(
85
+ name=resource.name,
86
+ ram=resource.ram,
87
+ vcpus=resource.vcpus,
88
+ disk=resource.disk,
89
+ id=resource.flavor_id,
90
+ ephemeral=resource.ephemeral,
91
+ swap=resource.swap,
92
+ rxtx_factor=resource.rxtx_factor,
93
+ is_public=resource.is_public,
94
+ )
95
+
96
+ if resource.extra_specs:
97
+ self._connection.compute.create_flavor_extra_specs(
98
+ flavor, resource.extra_specs
99
+ )
100
+ return flavor
101
+
102
+ def _delete_flavor(self) -> None:
103
+ self._connection.compute.delete_flavor(self.flavor.id, ignore_missing=True)
104
+
82
105
  def read_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
83
106
  self.flavor = self._get_flavor(resource)
84
107
  if self.flavor is None:
@@ -101,27 +124,12 @@ class FlavorHandler(OpenStackHandler):
101
124
  self._set_facts(ctx)
102
125
 
103
126
  def create_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
104
- self.flavor = self._connection.compute.create_flavor(
105
- name=resource.name,
106
- ram=resource.ram,
107
- vcpus=resource.vcpus,
108
- disk=resource.disk,
109
- id=resource.flavor_id,
110
- ephemeral=resource.ephemeral,
111
- swap=resource.swap,
112
- rxtx_factor=resource.rxtx_factor,
113
- is_public=resource.is_public,
114
- )
115
-
116
- if resource.extra_specs:
117
- self._connection.compute.create_flavor_extra_specs(
118
- self.flavor, resource.extra_specs
119
- )
127
+ self.flavor = self._create_flavor(resource)
120
128
  self._set_facts(ctx)
121
129
  ctx.set_created()
122
130
 
123
131
  def delete_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
124
- self._connection.compute.delete_flavor(self.flavor.id, ignore_missing=True)
132
+ self._delete_flavor()
125
133
  ctx.set_purged()
126
134
 
127
135
  def update_resource(
@@ -130,31 +138,33 @@ class FlavorHandler(OpenStackHandler):
130
138
  changes: dict,
131
139
  resource: Flavor,
132
140
  ) -> None:
133
- self.validate_allowed_fields(
134
- changes,
135
- {
136
- "extra_specs",
137
- },
138
- )
139
-
140
- if "extra_specs" in changes:
141
- new_extra_specs = changes["extra_specs"]["desired"]
142
- extra_specs_to_remove = [
143
- key for key in self.flavor.extra_specs if key not in new_extra_specs
144
- ]
145
- for key in extra_specs_to_remove:
146
- self._connection.compute.delete_flavor_extra_specs_property(
147
- self.flavor, key
148
- )
149
- self._connection.compute.create_flavor_extra_specs(
150
- self.flavor, new_extra_specs
141
+ """Update flavor parameters.
142
+
143
+ OpenStack flavors must be immutable, so *every* change - including
144
+ extra_specs - is applied by deleting the existing flavor and
145
+ recreating it. This is only safe when OpenStack assigns the id: an
146
+ explicit flavor_id would collide with the soft-deleted flavor, so we
147
+ refuse such changes instead.
148
+ """
149
+ if self._resource_has_explicit_flavor_id(resource):
150
+ raise ValueError(
151
+ f"Cannot change {sorted(changes)} on a flavor with an explicit "
152
+ f"flavor_id: recreating it would reuse the soft-deleted id. "
153
+ f"Remove the flavor and create a new one instead."
151
154
  )
152
- self.flavor = self._get_flavor(resource)
155
+
156
+ ctx.info(
157
+ "Recreating flavor; changed field(s): %(fields)s",
158
+ fields=sorted(changes),
159
+ )
160
+ self._delete_flavor()
161
+ self.flavor = self._create_flavor(resource)
162
+ self._set_facts(ctx)
153
163
  ctx.set_updated()
154
164
 
155
165
  def _set_facts(self, ctx):
156
- ctx.set_fact("flavor_id", self.flavor.id, expires=False)
157
- ctx.set_fact("is_disabled", str(self.flavor.is_disabled), expires=True)
166
+ ctx.set_fact("flavor_id", self.flavor.id)
167
+ ctx.set_fact("is_disabled", str(self.flavor.is_disabled))
158
168
 
159
169
  @cache(timeout=5)
160
170
  def facts(self, ctx, resource: Flavor) -> dict:
@@ -0,0 +1,117 @@
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 inmanta.agent import handler
20
+ from inmanta.agent.handler import ResourcePurged, SkipResource, provider
21
+ from inmanta.resources import resource
22
+
23
+ from inmanta_plugins.openstack.common.base import (
24
+ OpenstackAdminResource,
25
+ OpenStackHandler,
26
+ )
27
+
28
+
29
+ @resource(
30
+ "openstack::HostAggregateMetadata",
31
+ agent="provider.name",
32
+ id_attribute="metadata_id",
33
+ )
34
+ class HostAggregateMetadata(OpenstackAdminResource):
35
+ """A single key/value metadata item on a Nova host aggregate."""
36
+
37
+ fields = ("aggregate", "key", "value")
38
+ aggregate: str
39
+ key: str
40
+ value: str
41
+
42
+ @staticmethod
43
+ def get_metadata_id(_, resource) -> str:
44
+ # Unique per (aggregate, key) within the provider's agent.
45
+ return f"{resource.aggregate}::{resource.key}"
46
+
47
+
48
+ @provider("openstack::HostAggregateMetadata", name="openstack")
49
+ class HostAggregateMetadataHandler(OpenStackHandler):
50
+ """Manage a single metadata key on a host aggregate.
51
+
52
+ create/update -> set the key to the desired value
53
+ delete -> remove the key (Nova removes a key when its value is None),
54
+ leaving any other keys on the aggregate untouched
55
+ """
56
+
57
+ def _get_aggregate(self, resource: HostAggregateMetadata, *, required: bool):
58
+ aggregate = self._connection.compute.find_aggregate(
59
+ resource.aggregate, ignore_missing=True
60
+ )
61
+ if aggregate is None and required:
62
+ raise SkipResource(
63
+ f"Host aggregate '{resource.aggregate}' does not exist; cannot manage "
64
+ f"metadata key '{resource.key}'. The aggregate must be created first."
65
+ )
66
+ return aggregate
67
+
68
+ def read_resource(
69
+ self, ctx: handler.HandlerContext, resource: HostAggregateMetadata
70
+ ) -> None:
71
+ aggregate = self._get_aggregate(resource, required=True)
72
+ metadata = aggregate.metadata or {}
73
+
74
+ if resource.key not in metadata:
75
+ raise ResourcePurged()
76
+
77
+ resource.purged = False
78
+ # Surface the current value so a changed value is detected as a diff.
79
+ resource.value = metadata[resource.key]
80
+
81
+ def create_resource(
82
+ self, ctx: handler.HandlerContext, resource: HostAggregateMetadata
83
+ ) -> None:
84
+ aggregate = self._get_aggregate(resource, required=True)
85
+ self._connection.compute.set_aggregate_metadata(
86
+ aggregate, metadata={resource.key: resource.value}
87
+ )
88
+ ctx.set_created()
89
+
90
+ def update_resource(
91
+ self,
92
+ ctx: handler.HandlerContext,
93
+ changes: dict,
94
+ resource: HostAggregateMetadata,
95
+ ) -> None:
96
+ # Only the value can change; aggregate and key are part of the resource id.
97
+ aggregate = self._get_aggregate(resource, required=True)
98
+ self._connection.compute.set_aggregate_metadata(
99
+ aggregate, metadata={resource.key: resource.value}
100
+ )
101
+ ctx.set_updated()
102
+
103
+ def delete_resource(
104
+ self, ctx: handler.HandlerContext, resource: HostAggregateMetadata
105
+ ) -> None:
106
+ aggregate = self._get_aggregate(resource, required=False)
107
+ if aggregate is None:
108
+ # Aggregate gone -> the key is gone with it; nothing to unset.
109
+ ctx.set_purged()
110
+ return
111
+
112
+ # Nova removes a metadata key when it is set to None, leaving any other keys
113
+ # on the aggregate untouched.
114
+ self._connection.compute.set_aggregate_metadata(
115
+ aggregate, metadata={resource.key: None}
116
+ )
117
+ ctx.set_purged()
@@ -47,6 +47,7 @@ class HostPort(Port):
47
47
  "wait",
48
48
  "allowed_address_pairs",
49
49
  "wait_for_vm",
50
+ "security_groups",
50
51
  )
51
52
  host: str
52
53
  portsecurity: bool
@@ -56,6 +57,7 @@ class HostPort(Port):
56
57
  wait: bool
57
58
  allowed_address_pairs: dict[str, Optional[str]]
58
59
  wait_for_vm: bool
60
+ security_groups: list[str]
59
61
 
60
62
  @staticmethod
61
63
  def get_host(_, port):
@@ -77,6 +79,10 @@ class HostPort(Port):
77
79
  """field used to determine if we expect the VM to be present at all"""
78
80
  return not (port.vm.purged)
79
81
 
82
+ @staticmethod
83
+ def get_security_groups(_, port):
84
+ return sorted([v.name for v in port.security_groups])
85
+
80
86
 
81
87
  @provider("openstack::HostPort", name="openstack")
82
88
  class HostPortHandler(OpenStackHandler):
@@ -182,6 +188,13 @@ class HostPortHandler(OpenStackHandler):
182
188
  pair["ip_address"]: pair.get("mac_address")
183
189
  for pair in self.port.allowed_address_pairs
184
190
  }
191
+
192
+ if resource.security_groups:
193
+ resource.security_groups = sorted(
194
+ self.get_security_group(project_id=self.project_id, group_id=sg_id).name
195
+ for sg_id in self.port.security_group_ids
196
+ )
197
+
185
198
  resource.name = self.port.name
186
199
  self._set_facts(ctx)
187
200
 
@@ -209,7 +222,13 @@ class HostPortHandler(OpenStackHandler):
209
222
  ]
210
223
 
211
224
  port_attrs["port_security_enabled"] = resource.portsecurity
212
- port_attrs["security_groups"] = None
225
+ if resource.security_groups:
226
+ port_attrs["security_group_ids"] = [
227
+ self.get_security_group(project_id=self.project_id, name=sg).id
228
+ for sg in resource.security_groups
229
+ ]
230
+ else:
231
+ port_attrs["security_group_ids"] = None
213
232
 
214
233
  # Allowed address pairs
215
234
  if resource.allowed_address_pairs:
@@ -248,16 +267,27 @@ class HostPortHandler(OpenStackHandler):
248
267
  resource: HostPort,
249
268
  ) -> None:
250
269
  self.validate_allowed_fields(
251
- changes, {"portsecurity", "name", "allowed_address_pairs"}
270
+ changes,
271
+ {"portsecurity", "name", "allowed_address_pairs", "security_groups"},
272
+ )
273
+
274
+ disabling_portsecurity = (
275
+ "portsecurity" in changes and not changes["portsecurity"]["desired"]
252
276
  )
253
277
 
254
278
  if self.port.is_port_security_enabled and "portsecurity" in changes:
255
279
  if not changes["portsecurity"]["desired"]:
256
280
  self.port.is_port_security_enabled = False
257
- self.port.security_groups = None
281
+ self.port.security_group_ids = []
258
282
  else:
259
283
  raise SkipResource("Turning port security on again is not supported.")
260
284
 
285
+ if "security_groups" in changes and not disabling_portsecurity:
286
+ self.port.security_group_ids = [
287
+ self.get_security_group(project_id=self.project_id, name=sg).id
288
+ for sg in resource.security_groups
289
+ ]
290
+
261
291
  # Handle name change
262
292
  if "name" in changes:
263
293
  self.port.name = resource.name
@@ -57,6 +57,7 @@ class VirtualMachine(OpenstackResource):
57
57
  "metadata",
58
58
  "personality",
59
59
  "block_devices",
60
+ "availability_zone",
60
61
  )
61
62
  name: str
62
63
  flavor: str
@@ -69,6 +70,7 @@ class VirtualMachine(OpenstackResource):
69
70
  config_drive: bool
70
71
  metadata: dict[str, str]
71
72
  personality: dict[str, str]
73
+ availability_zone: str | None
72
74
  block_devices: list[dict]
73
75
 
74
76
  @staticmethod
@@ -282,8 +284,17 @@ class VirtualMachineHandler(OpenStackHandler):
282
284
  )
283
285
 
284
286
  resource.purged = False
285
- self._connection.compute.fetch_server_security_groups(self.server)
286
- resource.security_groups = [sg["name"] for sg in self.server.security_groups]
287
+ if resource.security_groups:
288
+ # An empty relation leaves the server-level security groups unmanaged, so port
289
+ # security groups (managed through neutron) can be used for fine-grained control
290
+ # without the VM handler fighting over the nova server-level groups.
291
+ self._connection.compute.fetch_server_security_groups(self.server)
292
+ resource.security_groups = [
293
+ sg["name"] for sg in self.server.security_groups
294
+ ]
295
+ # AZ is create-only; reflect the live value only when pinned to avoid spurious drift.
296
+ if resource.availability_zone is not None:
297
+ resource.availability_zone = self.server.availability_zone
287
298
  # The port handler has to handle all network/port related changes
288
299
  self._set_facts(ctx)
289
300
 
@@ -316,6 +327,8 @@ class VirtualMachineHandler(OpenStackHandler):
316
327
  args["block_device_mapping_v2"] = list(resource.block_devices)
317
328
  else:
318
329
  args["image_id"] = resource.image
330
+ if resource.availability_zone is not None:
331
+ args["availability_zone"] = resource.availability_zone
319
332
  ctx.info(
320
333
  f"Creating server with name {resource.name} and options",
321
334
  options=args,
@@ -365,7 +365,16 @@ entity HostPort extends Port:
365
365
  int wait=5
366
366
  end
367
367
 
368
+ implementation noSecurityGroupsWhenPortSecurityDisabled for HostPort:
369
+ # Security groups can only be enforced on a port when port security is enabled.
370
+ std::assert(
371
+ std::count(self.security_groups) == 0,
372
+ "HostPort '{{name}}' cannot have security_groups assigned while portsecurity is disabled."
373
+ )
374
+ end
375
+
368
376
  implement HostPort using providerRequire
377
+ implement HostPort using noSecurityGroupsWhenPortSecurityDisabled when not self.portsecurity
369
378
 
370
379
  HostPort.subnet [1] -- Subnet.host_ports [0:]
371
380
  HostPort.vm [1] -- VirtualMachine.ports [0:]
@@ -618,6 +627,12 @@ end
618
627
  SecurityGroup.provider [1] -- Provider.security_groups [0:]
619
628
  SecurityGroup.project [1] -- Project.security_groups [0:]
620
629
  SecurityGroup.virtual_machines [0:] -- VirtualMachine.security_groups [0:]
630
+ HostPort.security_groups [0:] -- SecurityGroup
631
+ """
632
+ :rel security_groups: The security groups to enforce on this port. When left empty (the default), the
633
+ port's security groups are left unmanaged for backward compatibility: existing groups (including the
634
+ Neutron ``default`` group) are kept untouched and never cleared. Requires portsecurity to be enabled.
635
+ """
621
636
 
622
637
  typedef protocol as string matching self in ["tcp", "udp", "icmp", "sctp", "all"]
623
638
 
@@ -683,6 +698,7 @@ entity VMAttributes:
683
698
  :param metadata: A dict of metadata items
684
699
  :param personality: A dict of files (personality)
685
700
  :param config_drive: Attach a configuration drive to the vm
701
+ :param availability_zone: Nova availability zone to schedule the VM in. When null, Nova chooses.
686
702
  """
687
703
  string flavor
688
704
  string? image = null
@@ -690,6 +706,7 @@ entity VMAttributes:
690
706
  dict metadata={}
691
707
  dict personality={}
692
708
  bool config_drive=false
709
+ string? availability_zone = null
693
710
  end
694
711
 
695
712
  entity VirtualMachine extends OpenStackResource, VMAttributes:
@@ -770,6 +787,32 @@ end
770
787
 
771
788
  implement Flavor using providerRequire
772
789
 
790
+ index HostAggregateMetadata(provider, aggregate, key)
791
+ HostAggregateMetadata.provider [1] -- Provider.host_aggregate_metadata [0:]
792
+ entity HostAggregateMetadata extends OpenStackResource:
793
+ """
794
+ A single metadata item (key/value) on a Nova host aggregate.
795
+
796
+ Each instance manages exactly one key on the named aggregate and leaves all
797
+ other keys untouched, so multiple instances - and multiple consumers - can
798
+ safely manage different keys on the same aggregate. Purging the resource removes
799
+ the key from the aggregate again.
800
+
801
+ A typical use case is the ``AggregateMultiTenancyIsolation`` scheduler filter,
802
+ where keys of the form ``filter_tenant_id_<suffix>`` pin projects to an aggregate.
803
+
804
+ :attr aggregate: Name of the host aggregate to manage the metadata on. The
805
+ aggregate must already exist; this resource does not create or delete it.
806
+ :attr key: The metadata key to manage.
807
+ :attr value: The value to set for ``key``.
808
+ """
809
+ string aggregate
810
+ string key
811
+ string value
812
+ end
813
+
814
+ implement HostAggregateMetadata using providerRequire
815
+
773
816
  index Image(provider, name)
774
817
  Image.provider [1] -- Provider.images [0:]
775
818
 
@@ -70,7 +70,7 @@ class NetworkPortHandler(OpenStackHandler):
70
70
  "Cannot read a port when the project id is not yet known."
71
71
  )
72
72
 
73
- self.network = self.get_network(None, resource.network)
73
+ self.network = self.get_network(self.project_id, resource.network)
74
74
  if self.network is None:
75
75
  raise SkipResource(
76
76
  "Network {} for port {} not found.".format(
@@ -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 = 5.0.2
25
+ version = 5.1.0
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 = 5.0.2
25
+ version = 5.1.0
26
26
  license = Apache 2.0
27
27
 
28
28
  [egg_info]