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.
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/PKG-INFO +1 -1
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/SOURCES.txt +7 -1
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/common/base.py +75 -9
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/common/deps.py +60 -11
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/flavor.py +91 -18
- inmanta_module_openstack-6.1.0/inmanta_plugins/openstack/compute/server_group.py +87 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/virtual_machine.py +80 -16
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/model/_init.cf +86 -4
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/network_port.py +82 -38
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/security_group.py +38 -7
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/setup.cfg +1 -1
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/setup.cfg +1 -1
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_dependency_handling.py +64 -0
- inmanta_module_openstack-6.1.0/tests/test_flavor.py +330 -0
- inmanta_module_openstack-6.1.0/tests/test_network_port_missing_network.py +176 -0
- inmanta_module_openstack-6.1.0/tests/test_network_port_shared_network.py +263 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_neutron.py +6 -3
- inmanta_module_openstack-6.1.0/tests/test_security_group_ethertype.py +157 -0
- inmanta_module_openstack-6.1.0/tests/test_server_group.py +397 -0
- inmanta_module_openstack-6.1.0/tests/test_vm_boot_nic.py +321 -0
- inmanta_module_openstack-5.1.2/tests/test_flavor.py +0 -227
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/MANIFEST.in +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/README.md +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/requires.txt +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/common/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/compute/host_aggregate.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/group.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/group_role.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/project.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/role.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/identity/user.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/image/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/image/image.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/network.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/router.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/router_port.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/subnet.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/quota/__init__.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/inmanta_plugins/openstack/quota/quota.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/pyproject.toml +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_block_devices.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_examples.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_host_aggregate.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_image.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_keystone.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_network_port_fixed_ips.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_network_port_security_groups.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_nova.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_quota.py +0 -0
- {inmanta_module_openstack-5.1.2 → inmanta_module_openstack-6.1.0}/tests/test_trunk.py +0 -0
|
@@ -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/
|
|
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
|
-
|
|
98
|
-
|
|
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(
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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["
|
|
138
|
-
vm.requires.add(subnets[port["
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
78
|
-
|
|
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(
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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()
|