inmanta-module-openstack 5.1.2__tar.gz → 6.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 (64) hide show
  1. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/SOURCES.txt +7 -1
  4. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/common/base.py +75 -9
  5. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/common/deps.py +60 -11
  6. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/flavor.py +91 -18
  7. inmanta_module_openstack-6.1.0/inmanta_plugins/openstack/compute/server_group.py +87 -0
  8. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/virtual_machine.py +80 -16
  9. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/model/_init.cf +86 -4
  10. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/network_port.py +82 -38
  11. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/security_group.py +38 -7
  12. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/setup.cfg +1 -1
  13. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/setup.cfg +1 -1
  14. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_dependency_handling.py +64 -0
  15. inmanta_module_openstack-6.1.0/tests/test_flavor.py +330 -0
  16. inmanta_module_openstack-6.1.0/tests/test_network_port_missing_network.py +176 -0
  17. inmanta_module_openstack-6.1.0/tests/test_network_port_shared_network.py +263 -0
  18. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_neutron.py +6 -3
  19. inmanta_module_openstack-6.1.0/tests/test_security_group_ethertype.py +157 -0
  20. inmanta_module_openstack-6.1.0/tests/test_server_group.py +397 -0
  21. inmanta_module_openstack-6.1.0/tests/test_vm_boot_nic.py +321 -0
  22. inmanta_module_openstack-5.1.2/tests/test_flavor.py +0 -227
  23. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/MANIFEST.in +0 -0
  24. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/README.md +0 -0
  25. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  26. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  27. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  28. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  29. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/__init__.py +0 -0
  30. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/common/__init__.py +0 -0
  31. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  32. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/host_aggregate.py +0 -0
  33. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  34. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/group.py +0 -0
  35. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  36. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  37. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/project.py +0 -0
  38. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/role.py +0 -0
  39. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/user.py +0 -0
  40. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/image/__init__.py +0 -0
  41. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/image/image.py +0 -0
  42. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/__init__.py +0 -0
  43. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  44. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/network.py +0 -0
  45. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  46. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/router.py +0 -0
  47. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/router_port.py +0 -0
  48. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/subnet.py +0 -0
  49. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  50. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  51. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  52. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  53. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/quota/quota.py +0 -0
  54. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/pyproject.toml +0 -0
  55. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_block_devices.py +0 -0
  56. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_examples.py +0 -0
  57. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_host_aggregate.py +0 -0
  58. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_image.py +0 -0
  59. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_keystone.py +0 -0
  60. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_network_port_fixed_ips.py +0 -0
  61. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_network_port_security_groups.py +0 -0
  62. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_nova.py +0 -0
  63. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_quota.py +0 -0
  64. {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.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.1.2
3
+ Version: 6.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.1.2
3
+ Version: 6.1.0
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -16,6 +16,7 @@ inmanta_plugins/openstack/common/deps.py
16
16
  inmanta_plugins/openstack/compute/__init__.py
17
17
  inmanta_plugins/openstack/compute/flavor.py
18
18
  inmanta_plugins/openstack/compute/host_aggregate.py
19
+ inmanta_plugins/openstack/compute/server_group.py
19
20
  inmanta_plugins/openstack/compute/virtual_machine.py
20
21
  inmanta_plugins/openstack/identity/__init__.py
21
22
  inmanta_plugins/openstack/identity/group.py
@@ -49,8 +50,13 @@ tests/test_host_aggregate.py
49
50
  tests/test_image.py
50
51
  tests/test_keystone.py
51
52
  tests/test_network_port_fixed_ips.py
53
+ tests/test_network_port_missing_network.py
52
54
  tests/test_network_port_security_groups.py
55
+ tests/test_network_port_shared_network.py
53
56
  tests/test_neutron.py
54
57
  tests/test_nova.py
55
58
  tests/test_quota.py
56
- tests/test_trunk.py
59
+ tests/test_security_group_ethertype.py
60
+ tests/test_server_group.py
61
+ tests/test_trunk.py
62
+ tests/test_vm_boot_nic.py
@@ -18,9 +18,11 @@ Contact: code@inmanta.com
18
18
 
19
19
  import openstack.connection
20
20
  import openstack.exceptions
21
- from inmanta.agent.handler import CRUDHandler, cache
21
+ from inmanta.agent.handler import CRUDHandler, SkipResource, cache
22
22
  from inmanta.execute import proxy
23
23
  from inmanta.resources import ManagedResource, PurgeableResource
24
+ from openstack.network.v2.network import Network as OSNetwork
25
+ from openstack.network.v2.subnet import Subnet as OSSubnet
24
26
 
25
27
  CRED_TIMEOUT = 600 # OpenStackHandler
26
28
 
@@ -94,8 +96,12 @@ class Port(OpenstackResource):
94
96
 
95
97
  fields = ("name", "address", "subnet", "network")
96
98
  name: str
97
- address: str
98
- subnet: str
99
+ # A NetworkPort does not need a fixed ip, nor to be pinned to a subnet: its getters
100
+ # report None for both when the relation is unset. RouterPort re-declares them as
101
+ # mandatory, which its model cardinality guarantees. The network is always there,
102
+ # taken from the relation itself or from the subnet the port is pinned to.
103
+ address: str | None
104
+ subnet: str | None
99
105
  network: str
100
106
 
101
107
  @staticmethod
@@ -185,14 +191,30 @@ class OpenStackHandler(CRUDHandler):
185
191
  f"Project not found or not visible: {name}"
186
192
  )
187
193
 
188
- def get_network(self, project_id=None, name=None, network_id=None):
194
+ def get_network(
195
+ self,
196
+ project_id: str | None = None,
197
+ name: str | None = None,
198
+ network_id: str | None = None,
199
+ include_shared: bool = False,
200
+ include_external: bool = False,
201
+ ) -> OSNetwork | None:
189
202
  """
190
203
  Retrieve the network id based on the name of the network
204
+
205
+ :param include_shared: also consider networks owned by another project when the
206
+ project scoped lookup comes up empty, restricted to shared networks. A shared
207
+ network is owned by the project that created it, so a lookup scoped to a
208
+ project merely using it never finds it.
209
+ :param include_external: likewise for external networks, which another project
210
+ owns as well: the management network of an appliance typically is one. Note
211
+ that every project can read an external network, but only an admin can create
212
+ a port in its own project on one, unless it is shared as well.
191
213
  """
192
214
  if not name and not network_id:
193
215
  raise Exception("Either a name or an id needs to be provided.")
194
216
 
195
- query_params = {}
217
+ query_params: dict[str, object] = {}
196
218
  if project_id:
197
219
  query_params["project_id"] = project_id
198
220
  if name:
@@ -201,26 +223,70 @@ class OpenStackHandler(CRUDHandler):
201
223
  query_params["id"] = network_id
202
224
 
203
225
  networks = self._connection.network.networks(**query_params)
204
- return self.require_single_item(networks)
205
-
206
- def get_subnet(self, project_id=None, name=None, subnet_id=None):
226
+ network = self.require_single_item(networks)
227
+
228
+ if network is None and project_id and (include_shared or include_external):
229
+ del query_params["project_id"]
230
+ if include_shared:
231
+ networks = self._connection.network.networks(
232
+ **query_params, is_shared=True
233
+ )
234
+ network = self.require_single_item(networks)
235
+ if network is None and include_external:
236
+ networks = self._connection.network.networks(
237
+ **query_params, is_router_external=True
238
+ )
239
+ network = self.require_single_item(networks)
240
+
241
+ return network
242
+
243
+ def get_subnet(
244
+ self,
245
+ project_id: str | None = None,
246
+ name: str | None = None,
247
+ subnet_id: str | None = None,
248
+ network_id: str | None = None,
249
+ ) -> OSSubnet | None:
207
250
  """
208
251
  Retrieve the subnet based on name or id
209
252
  """
210
253
  if not name and not subnet_id:
211
254
  raise Exception("Either a name or an id needs to be provided.")
212
255
 
213
- query_params = {}
256
+ query_params: dict[str, object] = {}
214
257
  if project_id:
215
258
  query_params["project_id"] = project_id
216
259
  if name:
217
260
  query_params["name"] = name
218
261
  if subnet_id:
219
262
  query_params["id"] = subnet_id
263
+ if network_id:
264
+ query_params["network_id"] = network_id
220
265
 
221
266
  subnets = self._connection.network.subnets(**query_params)
222
267
  return self.require_single_item(subnets)
223
268
 
269
+ def get_server_group(self, name: str):
270
+ """
271
+ Retrieve a Nova server group by name.
272
+
273
+ Nova does not filter server groups by name server-side, so list the groups in the
274
+ connection's project scope and match on the name ourselves. Nova does allow
275
+ several groups to share a name, which cannot be managed unambiguously: skip
276
+ rather than pick one.
277
+ """
278
+ groups = [
279
+ group
280
+ for group in self._connection.compute.server_groups()
281
+ if group.name == name
282
+ ]
283
+ if len(groups) > 1:
284
+ raise SkipResource(
285
+ f"Found more than one server group named '{name}'; refusing "
286
+ "to manage a non-unique server group."
287
+ )
288
+ return groups[0] if groups else None
289
+
224
290
  def get_router(self, project_id=None, name=None, router_id=None):
225
291
  """
226
292
  Retrieve the router based on name or id
@@ -18,11 +18,17 @@ Contact: code@inmanta.com
18
18
 
19
19
  from collections import defaultdict
20
20
  from collections.abc import Mapping
21
+ from typing import TYPE_CHECKING, cast
21
22
 
22
23
  from inmanta.export import dependency_manager
23
24
 
24
25
  from inmanta_plugins.openstack.common.base import OpenstackResource
25
26
 
27
+ if TYPE_CHECKING:
28
+ # Only a name for the cast below: this export-time module does not depend on a
29
+ # handler module at runtime.
30
+ from inmanta_plugins.openstack.network.network_port import NetworkPort
31
+
26
32
  # Per-provider view of the collected resources: resource type -> resource name -> resource.
27
33
  ResourceCollector = Mapping[str, Mapping[str, OpenstackResource]]
28
34
 
@@ -35,6 +41,7 @@ MANAGED_DEPENDENCIES = {
35
41
  "openstack::NetworkPort",
36
42
  "openstack::FloatingIP",
37
43
  "openstack::SecurityGroup",
44
+ "openstack::ServerGroup",
38
45
  }
39
46
 
40
47
 
@@ -68,9 +75,14 @@ def set_dependencies_on_purged_resources(collector: ResourceCollector) -> None:
68
75
  vms = collector.get("openstack::VirtualMachine", {})
69
76
  ports = collector.get("openstack::NetworkPort", {})
70
77
  sgs = collector.get("openstack::SecurityGroup", {})
78
+ server_groups = collector.get("openstack::ServerGroup", {})
71
79
 
72
80
  # When the VM is purged, all its purged ports should be deployed first
73
81
  for vm in vms.values():
82
+ # A purged server group is removed only after the VMs scheduled into it are gone.
83
+ if vm.server_group in server_groups:
84
+ server_groups[vm.server_group].requires.add(vm.id)
85
+
74
86
  for vm_port in vm.ports:
75
87
  if vm_port["name"] in ports:
76
88
  port = ports[vm_port["name"]]
@@ -99,6 +111,7 @@ def set_dependencies_on_non_purged_resources(collector: ResourceCollector) -> No
99
111
  ports = collector.get("openstack::NetworkPort", {})
100
112
  fips = collector.get("openstack::FloatingIP", {})
101
113
  sgs = collector.get("openstack::SecurityGroup", {})
114
+ server_groups = collector.get("openstack::ServerGroup", {})
102
115
  router_map = {}
103
116
 
104
117
  # they require the project to exist
@@ -133,22 +146,40 @@ def set_dependencies_on_non_purged_resources(collector: ResourceCollector) -> No
133
146
  if vm.model.project.name in projects:
134
147
  vm.requires.add(projects[vm.model.project.name].id)
135
148
 
149
+ # The vm resolves each boot NIC through the port's subnet, or through its network
150
+ # when the port is not pinned to a subnet. Either way that object has to exist
151
+ # before the vm boots.
136
152
  for port in vm.ports:
137
- if port["network"] in subnets:
138
- vm.requires.add(subnets[port["network"]].id)
153
+ if port["subnet"] in subnets:
154
+ vm.requires.add(subnets[port["subnet"]].id)
155
+ elif port["network"] in networks:
156
+ vm.requires.add(networks[port["network"]].id)
139
157
 
140
158
  for sg in vm.security_groups:
141
159
  if sg in sgs:
142
160
  vm.requires.add(sgs[sg].id)
143
161
 
144
- for port in ports.values():
162
+ # The scheduler hint is resolved at boot, so the group must exist by then.
163
+ if vm.server_group in server_groups:
164
+ vm.requires.add(server_groups[vm.server_group].id)
165
+
166
+ for collected_port in ports.values():
167
+ # The collector hands out the base type; every entry here is a NetworkPort.
168
+ port = cast("NetworkPort", collected_port)
169
+
145
170
  if port.model.project.name in projects:
146
171
  port.requires.add(projects[port.model.project.name].id)
147
172
 
148
- if port.network in projects:
149
- port.requires.add(subnets[port.network].id)
173
+ # The port lives on a network, and is pinned to one of its subnets when it
174
+ # requests a fixed ip. The network has to exist before the port is created at
175
+ # all; the subnet has to exist before the port can pin a fixed ip in it.
176
+ if port.network in networks:
177
+ port.requires.add(networks[port.network].id)
178
+
179
+ if port.subnet is not None and port.subnet in subnets:
180
+ port.requires.add(subnets[port.subnet].id)
150
181
 
151
- if port.host in vms:
182
+ if port.host is not None and port.host in vms:
152
183
  if port.is_trunk_parent:
153
184
  # A trunk parent must exist (unbound) before its vm: the trunk is built
154
185
  # on it and the vm then boots on the pre-created port. Ordering the port
@@ -167,13 +198,31 @@ def set_dependencies_on_non_purged_resources(collector: ResourceCollector) -> No
167
198
  fip.requires.add(networks[fip.external_network].id)
168
199
 
169
200
  if fip.port in ports:
170
- port = ports[fip.port]
201
+ port = cast("NetworkPort", ports[fip.port])
171
202
  fip.requires.add(port.id)
172
203
 
173
- # find router on which this floating ip is added
174
- key = (fip.external_network, port.subnet)
175
- if key in router_map:
176
- fip.requires.add(router_map[key].id)
204
+ # Find the router this floating ip is routed over: the one with a gateway on
205
+ # the external network and an interface on the port's network. A port pinned
206
+ # to a subnet names that subnet; a port that only knows its network is
207
+ # matched through every managed subnet of that network instead, as the
208
+ # router is attached to the network by one of them.
209
+ if port.subnet is not None:
210
+ port_subnets = [port.subnet]
211
+ else:
212
+ # Matching networks by name is enough even though the collector spans
213
+ # every project of the provider: a network resource is identified by
214
+ # (provider, name), so two projects of one provider cannot both hold a
215
+ # network under this name.
216
+ port_subnets = [
217
+ subnet_name
218
+ for subnet_name, subnet in subnets.items()
219
+ if subnet.model.network.name == port.network
220
+ ]
221
+
222
+ for subnet_name in port_subnets:
223
+ key = (fip.external_network, subnet_name)
224
+ if key in router_map:
225
+ fip.requires.add(router_map[key].id)
177
226
 
178
227
 
179
228
  @dependency_manager
@@ -21,6 +21,7 @@ from typing import Any
21
21
  from inmanta.agent import handler
22
22
  from inmanta.agent.handler import ResourcePurged, cache, provider
23
23
  from inmanta.resources import resource
24
+ from openstack.compute.v2.flavor import Flavor as SdkFlavor
24
25
 
25
26
  from inmanta_plugins.openstack.common.base import (
26
27
  NotUniqueException,
@@ -45,6 +46,7 @@ class Flavor(OpenstackAdminResource):
45
46
  "swap",
46
47
  "rxtx_factor",
47
48
  "is_public",
49
+ "description",
48
50
  "extra_specs",
49
51
  )
50
52
  name: str
@@ -56,16 +58,25 @@ class Flavor(OpenstackAdminResource):
56
58
  swap: int
57
59
  rxtx_factor: float
58
60
  is_public: bool
59
- extra_specs: dict[str, Any]
61
+ description: str | None
62
+ extra_specs: dict[str, Any] | None
60
63
 
61
64
  @staticmethod
62
65
  def get_extra_specs(_, obj):
66
+ if obj.extra_specs is None:
67
+ return None
63
68
  return {key: str(val) for key, val in obj.extra_specs.items()}
64
69
 
65
70
 
66
71
  @provider("openstack::Flavor", name="openstack")
67
72
  class FlavorHandler(OpenStackHandler):
68
- # NOTE: Description is mentioned in the documentation, but does not seem to work currently
73
+ # Fields Nova can change on an existing flavor. Any other change requires
74
+ # deleting and recreating it.
75
+ IN_PLACE_FIELDS = {"description", "extra_specs"}
76
+
77
+ # Set by read_resource, which raises ResourcePurged when the flavor is gone,
78
+ # so the other handler methods can rely on it being there.
79
+ flavor: SdkFlavor
69
80
 
70
81
  def _resource_has_explicit_flavor_id(self, resource: Flavor) -> bool:
71
82
  return resource.flavor_id not in (None, "", "auto")
@@ -74,13 +85,26 @@ class FlavorHandler(OpenStackHandler):
74
85
  if self._resource_has_explicit_flavor_id(resource):
75
86
  return self._connection.get_flavor(resource.flavor_id, get_extra=True)
76
87
 
77
- flavors = self._connection.compute.flavors(
78
- name=resource.name, get_extra_specs=True
88
+ # Nova has no name filter on the flavor list, and openstacksdk silently
89
+ # discards unknown query parameters, so we have to match client-side.
90
+ flavors = (
91
+ flavor
92
+ for flavor in self._connection.compute.flavors(get_extra_specs=True)
93
+ if flavor.name == resource.name
79
94
  )
80
95
  return self.require_single_item(flavors)
81
96
 
82
- def _create_flavor(self, resource: Flavor):
83
- """Create a flavor in OpenStack."""
97
+ def _create_flavor(
98
+ self,
99
+ resource: Flavor,
100
+ description: str | None,
101
+ extra_specs: dict[str, Any] | None,
102
+ ):
103
+ """Create a flavor in OpenStack.
104
+
105
+ The description and extra specs are passed in rather than read off the
106
+ resource, so a recreate can carry over the values it does not manage.
107
+ """
84
108
  flavor = self._connection.compute.create_flavor(
85
109
  name=resource.name,
86
110
  ram=resource.ram,
@@ -91,17 +115,32 @@ class FlavorHandler(OpenStackHandler):
91
115
  swap=resource.swap,
92
116
  rxtx_factor=resource.rxtx_factor,
93
117
  is_public=resource.is_public,
118
+ description=description,
94
119
  )
95
120
 
96
- if resource.extra_specs:
97
- self._connection.compute.create_flavor_extra_specs(
98
- flavor, resource.extra_specs
99
- )
121
+ if extra_specs:
122
+ self._connection.compute.create_flavor_extra_specs(flavor, extra_specs)
100
123
  return flavor
101
124
 
102
125
  def _delete_flavor(self) -> None:
103
126
  self._connection.compute.delete_flavor(self.flavor.id, ignore_missing=True)
104
127
 
128
+ def _update_extra_specs(self, extra_specs: dict[str, Any]) -> None:
129
+ """Reconcile the extra specs of an existing flavor.
130
+
131
+ Nova has no endpoint to replace the whole set at once: keys that are no
132
+ longer desired have to be deleted one by one, the rest is overwritten in
133
+ a single call.
134
+ """
135
+ for key in self.flavor.extra_specs:
136
+ if key not in extra_specs:
137
+ self._connection.compute.delete_flavor_extra_specs_property(
138
+ self.flavor, key
139
+ )
140
+
141
+ if extra_specs:
142
+ self._connection.compute.create_flavor_extra_specs(self.flavor, extra_specs)
143
+
105
144
  def read_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
106
145
  self.flavor = self._get_flavor(resource)
107
146
  if self.flavor is None:
@@ -120,11 +159,18 @@ class FlavorHandler(OpenStackHandler):
120
159
  resource.swap = self.flavor.swap or 0
121
160
  resource.rxtx_factor = self.flavor.rxtx_factor
122
161
  resource.is_public = self.flavor.is_public
123
- resource.extra_specs = self.flavor.extra_specs
162
+ # A null description or extra_specs means "not managed": leaving the desired
163
+ # value in place keeps whatever is on the flavor out of the diff.
164
+ if resource.description is not None:
165
+ resource.description = self.flavor.description or ""
166
+ if resource.extra_specs is not None:
167
+ resource.extra_specs = self.flavor.extra_specs
124
168
  self._set_facts(ctx)
125
169
 
126
170
  def create_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
127
- self.flavor = self._create_flavor(resource)
171
+ self.flavor = self._create_flavor(
172
+ resource, resource.description, resource.extra_specs
173
+ )
128
174
  self._set_facts(ctx)
129
175
  ctx.set_created()
130
176
 
@@ -140,12 +186,27 @@ class FlavorHandler(OpenStackHandler):
140
186
  ) -> None:
141
187
  """Update flavor parameters.
142
188
 
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.
189
+ Nova lets us change the description and the extra specs on an existing
190
+ flavor. The sizing fields are immutable, so a change to any of those is
191
+ applied by deleting the existing flavor and recreating it. That is only
192
+ safe when OpenStack assigns the id: an explicit flavor_id would collide
193
+ with the soft-deleted flavor, so we refuse such changes instead.
194
+
195
+ A description or extra specs set to null are not managed, so they are
196
+ never updated, and a recreate carries the live values over rather than
197
+ dropping them.
148
198
  """
199
+ if not set(changes) - self.IN_PLACE_FIELDS:
200
+ if "description" in changes:
201
+ self._connection.compute.update_flavor(
202
+ self.flavor, description=resource.description
203
+ )
204
+ # Unmanaged fields never end up in changes, so both are set here.
205
+ if "extra_specs" in changes and resource.extra_specs is not None:
206
+ self._update_extra_specs(resource.extra_specs)
207
+ ctx.set_updated()
208
+ return
209
+
149
210
  if self._resource_has_explicit_flavor_id(resource):
150
211
  raise ValueError(
151
212
  f"Cannot change {sorted(changes)} on a flavor with an explicit "
@@ -157,8 +218,20 @@ class FlavorHandler(OpenStackHandler):
157
218
  "Recreating flavor; changed field(s): %(fields)s",
158
219
  fields=sorted(changes),
159
220
  )
221
+ # Carry over the fields the model does not manage, so recreating does not
222
+ # drop a description or specs set outside of it.
223
+ description = (
224
+ self.flavor.description
225
+ if resource.description is None
226
+ else resource.description
227
+ )
228
+ extra_specs = (
229
+ dict(self.flavor.extra_specs)
230
+ if resource.extra_specs is None
231
+ else resource.extra_specs
232
+ )
160
233
  self._delete_flavor()
161
- self.flavor = self._create_flavor(resource)
234
+ self.flavor = self._create_flavor(resource, description, extra_specs)
162
235
  self._set_facts(ctx)
163
236
  ctx.set_updated()
164
237
 
@@ -0,0 +1,87 @@
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
+ import logging
20
+
21
+ from inmanta.agent import handler
22
+ from inmanta.agent.handler import ResourcePurged, SkipResource, provider
23
+ from inmanta.resources import resource
24
+
25
+ from inmanta_plugins.openstack.common.base import OpenStackHandler, OpenstackResource
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+
29
+
30
+ @resource("openstack::ServerGroup", agent="provider.name", id_attribute="name")
31
+ class ServerGroup(OpenstackResource):
32
+ """A Nova server group with a scheduling policy."""
33
+
34
+ fields = ("name", "policy")
35
+ name: str
36
+ policy: str
37
+
38
+
39
+ @provider("openstack::ServerGroup", name="openstack")
40
+ class ServerGroupHandler(OpenStackHandler):
41
+ @staticmethod
42
+ def _policy_of(group) -> str:
43
+ # A server group always has exactly one policy. Nova returns it as a single
44
+ # string from microversion 2.64, and as a one-element list on older ones.
45
+ if group.policy:
46
+ return group.policy
47
+ return group.policies[0]
48
+
49
+ def read_resource(self, ctx: handler.HandlerContext, resource: ServerGroup) -> None:
50
+ group = self.get_server_group(resource.name)
51
+ if group is None:
52
+ raise ResourcePurged()
53
+
54
+ resource.purged = False
55
+ resource.policy = self._policy_of(group)
56
+ ctx.set_fact("server_group_id", group.id, expires=False)
57
+
58
+ def create_resource(
59
+ self, ctx: handler.HandlerContext, resource: ServerGroup
60
+ ) -> None:
61
+ # The soft-affinity/soft-anti-affinity policies require compute API microversion
62
+ # 2.15 or higher. openstacksdk negotiates up to the server's maximum supported
63
+ # microversion (>= 2.15 on any supported Nova) and translates the `policy`
64
+ # argument to the `policies` list on older clouds, so the policy is accepted.
65
+ group = self._connection.compute.create_server_group(
66
+ name=resource.name, policy=resource.policy
67
+ )
68
+ ctx.set_fact("server_group_id", group.id, expires=False)
69
+ ctx.set_created()
70
+
71
+ def update_resource(
72
+ self, ctx: handler.HandlerContext, changes: dict, resource: ServerGroup
73
+ ) -> None:
74
+ # A server group is immutable in Nova: its policy cannot be changed after
75
+ # creation. Surface the drift as a skip rather than silently ignoring it.
76
+ raise SkipResource(
77
+ "A server group's policy cannot be changed in place; delete and recreate "
78
+ "the server group to change its policy."
79
+ )
80
+
81
+ def delete_resource(
82
+ self, ctx: handler.HandlerContext, resource: ServerGroup
83
+ ) -> None:
84
+ group = self.get_server_group(resource.name)
85
+ if group is not None:
86
+ self._connection.compute.delete_server_group(group, ignore_missing=True)
87
+ ctx.set_purged()