inmanta-module-openstack 5.0.2__tar.gz → 5.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/PKG-INFO +1 -1
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/SOURCES.txt +4 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/deps.py +37 -15
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/flavor.py +48 -38
- inmanta_module_openstack-5.1.1/inmanta_plugins/openstack/compute/host_aggregate.py +117 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/host_port.py +120 -32
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/virtual_machine.py +176 -31
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/model/_init.cf +70 -4
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/network_port.py +16 -2
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/setup.cfg +1 -1
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/setup.cfg +1 -1
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_flavor.py +68 -3
- inmanta_module_openstack-5.1.1/tests/test_host_aggregate.py +138 -0
- inmanta_module_openstack-5.1.1/tests/test_host_port_fixed_ips.py +347 -0
- inmanta_module_openstack-5.1.1/tests/test_host_port_security_groups.py +441 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_neutron.py +81 -0
- inmanta_module_openstack-5.1.1/tests/test_nova.py +570 -0
- inmanta_module_openstack-5.0.2/tests/test_nova.py +0 -222
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/MANIFEST.in +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/README.md +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/requires.txt +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/base.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/group.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/group_role.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/project.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/role.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/user.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/image/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/image/image.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/network.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/router.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/router_port.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/security_group.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/subnet.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/quota/__init__.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/quota/quota.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/pyproject.toml +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_block_devices.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_dependency_handling.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_examples.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_image.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_keystone.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_quota.py +0 -0
- {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_trunk.py +0 -0
|
@@ -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,9 @@ 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_fixed_ips.py
|
|
51
|
+
tests/test_host_port_security_groups.py
|
|
48
52
|
tests/test_image.py
|
|
49
53
|
tests/test_keystone.py
|
|
50
54
|
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(
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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(
|
|
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(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
ctx.set_fact("is_disabled", str(self.flavor.is_disabled)
|
|
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()
|
|
@@ -16,6 +16,8 @@ limitations under the License.
|
|
|
16
16
|
Contact: code@inmanta.com
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
import ipaddress
|
|
20
|
+
import json
|
|
19
21
|
import time
|
|
20
22
|
from typing import Optional
|
|
21
23
|
|
|
@@ -35,7 +37,7 @@ from inmanta_plugins.openstack.common.base import (
|
|
|
35
37
|
@resource("openstack::HostPort", agent="provider.name", id_attribute="name")
|
|
36
38
|
class HostPort(Port):
|
|
37
39
|
"""
|
|
38
|
-
A port
|
|
40
|
+
A port attached to a VM.
|
|
39
41
|
"""
|
|
40
42
|
|
|
41
43
|
fields = (
|
|
@@ -47,6 +49,8 @@ class HostPort(Port):
|
|
|
47
49
|
"wait",
|
|
48
50
|
"allowed_address_pairs",
|
|
49
51
|
"wait_for_vm",
|
|
52
|
+
"security_groups",
|
|
53
|
+
"address_v6",
|
|
50
54
|
)
|
|
51
55
|
host: str
|
|
52
56
|
portsecurity: bool
|
|
@@ -56,6 +60,22 @@ class HostPort(Port):
|
|
|
56
60
|
wait: bool
|
|
57
61
|
allowed_address_pairs: dict[str, Optional[str]]
|
|
58
62
|
wait_for_vm: bool
|
|
63
|
+
security_groups: list[str]
|
|
64
|
+
address_v6: Optional[str]
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def get_address(_, port):
|
|
68
|
+
try:
|
|
69
|
+
return port.address
|
|
70
|
+
except ast.OptionalValueException:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def get_address_v6(_, port):
|
|
75
|
+
try:
|
|
76
|
+
return port.address_v6
|
|
77
|
+
except ast.OptionalValueException:
|
|
78
|
+
return None
|
|
59
79
|
|
|
60
80
|
@staticmethod
|
|
61
81
|
def get_host(_, port):
|
|
@@ -77,9 +97,18 @@ class HostPort(Port):
|
|
|
77
97
|
"""field used to determine if we expect the VM to be present at all"""
|
|
78
98
|
return not (port.vm.purged)
|
|
79
99
|
|
|
100
|
+
@staticmethod
|
|
101
|
+
def get_security_groups(_, port):
|
|
102
|
+
return sorted([v.name for v in port.security_groups])
|
|
103
|
+
|
|
80
104
|
|
|
81
105
|
@provider("openstack::HostPort", name="openstack")
|
|
82
106
|
class HostPortHandler(OpenStackHandler):
|
|
107
|
+
"""
|
|
108
|
+
Manage a neutron port on a VM. Its ``subnet`` relation may be an IPv4 or an
|
|
109
|
+
IPv6 (``SubnetBase``) subnet, so a port can live on an IPv6-only network.
|
|
110
|
+
"""
|
|
111
|
+
|
|
83
112
|
def _get_port(self, ctx, network_id, device_id):
|
|
84
113
|
"""
|
|
85
114
|
Retrieve a port attached to a given device (VM) on a specific network.
|
|
@@ -113,10 +142,13 @@ class HostPortHandler(OpenStackHandler):
|
|
|
113
142
|
if vm_state == "active":
|
|
114
143
|
return vm
|
|
115
144
|
else:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
if resource.wait_for_vm:
|
|
146
|
+
ctx.info(
|
|
147
|
+
"VM for port is not in active state, but %(state)s. Waiting and retrying in 5 seconds.",
|
|
148
|
+
state=vm_state,
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
raise ResourcePurged()
|
|
120
152
|
else:
|
|
121
153
|
if not resource.wait_for_vm:
|
|
122
154
|
# Vm doesn't exist, so we can assume this port doesn't exist either
|
|
@@ -159,21 +191,17 @@ class HostPortHandler(OpenStackHandler):
|
|
|
159
191
|
raise ResourcePurged()
|
|
160
192
|
|
|
161
193
|
resource.purged = False
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else:
|
|
174
|
-
resource.subnet = subnet.name
|
|
175
|
-
else:
|
|
176
|
-
resource.subnet = ""
|
|
194
|
+
|
|
195
|
+
# A fixed ip is pinned at creation and exposed through the fixed_ips fact; it is
|
|
196
|
+
# not reflected back into the resource, as reflecting it would surface a spurious
|
|
197
|
+
# diff on an address the handler does not reconcile. The one exception is a
|
|
198
|
+
# statically requested IPv6: a dual-stack port boots with only its IPv4 (a boot
|
|
199
|
+
# NIC holds a single fixed_ip), so drive an update when the requested address is
|
|
200
|
+
# not already assigned. Test for that exact address (a port may carry several
|
|
201
|
+
# IPv6, e.g. an extra subnet or a SLAAC address) and leave the attribute alone
|
|
202
|
+
# when no IPv6 was requested, so an auto-assigned address is not seen as a change.
|
|
203
|
+
if resource.address_v6 and not self._has_fixed_ip(resource.address_v6):
|
|
204
|
+
resource.address_v6 = None
|
|
177
205
|
|
|
178
206
|
resource.portsecurity = self.port.is_port_security_enabled
|
|
179
207
|
|
|
@@ -182,6 +210,13 @@ class HostPortHandler(OpenStackHandler):
|
|
|
182
210
|
pair["ip_address"]: pair.get("mac_address")
|
|
183
211
|
for pair in self.port.allowed_address_pairs
|
|
184
212
|
}
|
|
213
|
+
|
|
214
|
+
if resource.security_groups:
|
|
215
|
+
resource.security_groups = sorted(
|
|
216
|
+
self.get_security_group(project_id=self.project_id, group_id=sg_id).name
|
|
217
|
+
for sg_id in self.port.security_group_ids
|
|
218
|
+
)
|
|
219
|
+
|
|
185
220
|
resource.name = self.port.name
|
|
186
221
|
self._set_facts(ctx)
|
|
187
222
|
|
|
@@ -203,13 +238,26 @@ class HostPortHandler(OpenStackHandler):
|
|
|
203
238
|
"project_id": self.project_id,
|
|
204
239
|
}
|
|
205
240
|
|
|
206
|
-
if
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
241
|
+
if not resource.dhcp:
|
|
242
|
+
fixed_ips = []
|
|
243
|
+
if resource.address:
|
|
244
|
+
fixed_ips.append(
|
|
245
|
+
{"subnet_id": subnet.id, "ip_address": resource.address}
|
|
246
|
+
)
|
|
247
|
+
if resource.address_v6:
|
|
248
|
+
# No subnet_id: neutron allocates from the matching IPv6 subnet
|
|
249
|
+
fixed_ips.append({"ip_address": resource.address_v6})
|
|
250
|
+
if fixed_ips:
|
|
251
|
+
port_attrs["fixed_ips"] = fixed_ips
|
|
210
252
|
|
|
211
253
|
port_attrs["port_security_enabled"] = resource.portsecurity
|
|
212
|
-
|
|
254
|
+
if resource.security_groups:
|
|
255
|
+
port_attrs["security_group_ids"] = [
|
|
256
|
+
self.get_security_group(project_id=self.project_id, name=sg).id
|
|
257
|
+
for sg in resource.security_groups
|
|
258
|
+
]
|
|
259
|
+
else:
|
|
260
|
+
port_attrs["security_group_ids"] = None
|
|
213
261
|
|
|
214
262
|
# Allowed address pairs
|
|
215
263
|
if resource.allowed_address_pairs:
|
|
@@ -248,16 +296,33 @@ class HostPortHandler(OpenStackHandler):
|
|
|
248
296
|
resource: HostPort,
|
|
249
297
|
) -> None:
|
|
250
298
|
self.validate_allowed_fields(
|
|
251
|
-
changes,
|
|
299
|
+
changes,
|
|
300
|
+
{
|
|
301
|
+
"portsecurity",
|
|
302
|
+
"name",
|
|
303
|
+
"allowed_address_pairs",
|
|
304
|
+
"security_groups",
|
|
305
|
+
"address_v6",
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
disabling_portsecurity = (
|
|
310
|
+
"portsecurity" in changes and not changes["portsecurity"]["desired"]
|
|
252
311
|
)
|
|
253
312
|
|
|
254
313
|
if self.port.is_port_security_enabled and "portsecurity" in changes:
|
|
255
314
|
if not changes["portsecurity"]["desired"]:
|
|
256
315
|
self.port.is_port_security_enabled = False
|
|
257
|
-
self.port.
|
|
316
|
+
self.port.security_group_ids = []
|
|
258
317
|
else:
|
|
259
318
|
raise SkipResource("Turning port security on again is not supported.")
|
|
260
319
|
|
|
320
|
+
if "security_groups" in changes and not disabling_portsecurity:
|
|
321
|
+
self.port.security_group_ids = [
|
|
322
|
+
self.get_security_group(project_id=self.project_id, name=sg).id
|
|
323
|
+
for sg in resource.security_groups
|
|
324
|
+
]
|
|
325
|
+
|
|
261
326
|
# Handle name change
|
|
262
327
|
if "name" in changes:
|
|
263
328
|
self.port.name = resource.name
|
|
@@ -269,18 +334,41 @@ class HostPortHandler(OpenStackHandler):
|
|
|
269
334
|
]
|
|
270
335
|
self.port.allowed_address_pairs = allowed_address_pairs
|
|
271
336
|
|
|
337
|
+
if "address_v6" in changes:
|
|
338
|
+
# The requested IPv6 is not on the port (a dual-stack port boots with only
|
|
339
|
+
# its IPv4). Add it, keeping the existing v4 and any other assigned ips;
|
|
340
|
+
# neutron picks the matching subnet. Updating fixed_ips does not move the
|
|
341
|
+
# port's interface, so its boot-time ordering is preserved.
|
|
342
|
+
self.port.fixed_ips = list(self.port.fixed_ips) + [
|
|
343
|
+
{"ip_address": resource.address_v6}
|
|
344
|
+
]
|
|
345
|
+
|
|
272
346
|
try:
|
|
273
347
|
self.port = self._connection.network.update_port(self.port)
|
|
274
348
|
except openstack.exceptions.ConflictException as e:
|
|
275
349
|
raise SkipResource(f"Port is not ready: {e}")
|
|
350
|
+
|
|
351
|
+
# The fixed ips may have changed; re-report the fixed_ips fact for this deploy.
|
|
352
|
+
self._set_facts(ctx)
|
|
276
353
|
ctx.set_updated()
|
|
277
354
|
|
|
278
|
-
def
|
|
279
|
-
|
|
280
|
-
if i > 1:
|
|
281
|
-
# report only additional ip addresses not present in model
|
|
282
|
-
ctx.set_fact(f"ip_address_{i}", ip["ip_address"], expires=True)
|
|
355
|
+
def _has_fixed_ip(self, address: str) -> bool:
|
|
356
|
+
"""Whether the neutron port already has ``address`` as a fixed ip.
|
|
283
357
|
|
|
358
|
+
Compares normalised addresses so equivalent textual forms of the same IPv6
|
|
359
|
+
(e.g. ``2001:db8::1`` and ``2001:0db8::0001``) are treated as equal.
|
|
360
|
+
"""
|
|
361
|
+
target = ipaddress.ip_address(address)
|
|
362
|
+
return any(
|
|
363
|
+
ipaddress.ip_address(ip["ip_address"]) == target
|
|
364
|
+
for ip in self.port.fixed_ips
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _set_facts(self, ctx):
|
|
368
|
+
# Report every assigned ip as a plain list of addresses. This is the only channel
|
|
369
|
+
# exposing IPv6 and pool-assigned addresses, which the model cannot hold.
|
|
370
|
+
fixed_ips = [ip["ip_address"] for ip in self.port.fixed_ips]
|
|
371
|
+
ctx.set_fact("fixed_ips", json.dumps(fixed_ips), expires=True)
|
|
284
372
|
ctx.set_fact("mac_address", self.port.mac_address, expires=True)
|
|
285
373
|
ctx.set_fact("port_id", self.port.id, expires=False)
|
|
286
374
|
|