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.
Files changed (59) hide show
  1. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/SOURCES.txt +4 -0
  4. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/deps.py +37 -15
  5. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/flavor.py +48 -38
  6. inmanta_module_openstack-5.1.1/inmanta_plugins/openstack/compute/host_aggregate.py +117 -0
  7. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/host_port.py +120 -32
  8. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/virtual_machine.py +176 -31
  9. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/model/_init.cf +70 -4
  10. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/network_port.py +16 -2
  11. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/setup.cfg +1 -1
  12. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/setup.cfg +1 -1
  13. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_flavor.py +68 -3
  14. inmanta_module_openstack-5.1.1/tests/test_host_aggregate.py +138 -0
  15. inmanta_module_openstack-5.1.1/tests/test_host_port_fixed_ips.py +347 -0
  16. inmanta_module_openstack-5.1.1/tests/test_host_port_security_groups.py +441 -0
  17. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_neutron.py +81 -0
  18. inmanta_module_openstack-5.1.1/tests/test_nova.py +570 -0
  19. inmanta_module_openstack-5.0.2/tests/test_nova.py +0 -222
  20. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/MANIFEST.in +0 -0
  21. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/README.md +0 -0
  22. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  23. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  24. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  25. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  26. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/__init__.py +0 -0
  27. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/__init__.py +0 -0
  28. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/base.py +0 -0
  29. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  30. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  31. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/group.py +0 -0
  32. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  33. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  34. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/project.py +0 -0
  35. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/role.py +0 -0
  36. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/user.py +0 -0
  37. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/image/__init__.py +0 -0
  38. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/image/image.py +0 -0
  39. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/__init__.py +0 -0
  40. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  41. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/network.py +0 -0
  42. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  43. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/router.py +0 -0
  44. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/router_port.py +0 -0
  45. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/security_group.py +0 -0
  46. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/subnet.py +0 -0
  47. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  48. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  49. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  50. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  51. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/quota/quota.py +0 -0
  52. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/pyproject.toml +0 -0
  53. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_block_devices.py +0 -0
  54. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_dependency_handling.py +0 -0
  55. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_examples.py +0 -0
  56. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_image.py +0 -0
  57. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_keystone.py +0 -0
  58. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_quota.py +0 -0
  59. {inmanta_module_openstack-5.0.2 → inmanta_module_openstack-5.1.1}/tests/test_trunk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inmanta-module-openstack
3
- Version: 5.0.2
3
+ Version: 5.1.1
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inmanta-module-openstack
3
- Version: 5.0.2
3
+ Version: 5.1.1
4
4
  License: Apache 2.0
5
5
  Requires-Dist: inmanta-module-ssh
6
6
  Requires-Dist: inmanta-module-std
@@ -15,6 +15,7 @@ inmanta_plugins/openstack/common/base.py
15
15
  inmanta_plugins/openstack/common/deps.py
16
16
  inmanta_plugins/openstack/compute/__init__.py
17
17
  inmanta_plugins/openstack/compute/flavor.py
18
+ inmanta_plugins/openstack/compute/host_aggregate.py
18
19
  inmanta_plugins/openstack/compute/host_port.py
19
20
  inmanta_plugins/openstack/compute/virtual_machine.py
20
21
  inmanta_plugins/openstack/identity/__init__.py
@@ -45,6 +46,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(resource_model):
38
- purged_resources = defaultdict(lambda: defaultdict(dict))
39
- non_purged_resources = defaultdict(lambda: defaultdict(dict))
41
+ def resource_collector(
42
+ resource_model: Mapping[object, OpenstackResource],
43
+ ) -> tuple[Mapping[object, ResourceCollector], Mapping[object, ResourceCollector]]:
44
+ purged_resources: dict[object, dict[str, dict[str, OpenstackResource]]] = (
45
+ defaultdict(lambda: defaultdict(dict))
46
+ )
47
+ non_purged_resources: dict[object, dict[str, dict[str, OpenstackResource]]] = (
48
+ defaultdict(lambda: defaultdict(dict))
49
+ )
40
50
 
41
51
  for current_resource in resource_model.values():
42
52
  rtype = current_resource.id.entity_type
@@ -51,14 +61,13 @@ def resource_collector(resource_model):
51
61
  return purged_resources, non_purged_resources
52
62
 
53
63
 
54
- def set_dependencies_on_purged_resources(
55
- collector: dict[str, OpenstackResource],
56
- ) -> None:
64
+ def set_dependencies_on_purged_resources(collector: ResourceCollector) -> None:
57
65
  """
58
- This method only sets the dependencies between a VM and its ports!
66
+ Set the dependencies between purged VMs/ports and the security groups they reference.
59
67
  """
60
68
  vms = collector.get("openstack::VirtualMachine", {})
61
69
  ports = collector.get("openstack::HostPort", {})
70
+ sgs = collector.get("openstack::SecurityGroup", {})
62
71
 
63
72
  # When the VM is purged, all its purged ports should be deployed first
64
73
  for vm in vms.values():
@@ -67,10 +76,15 @@ def set_dependencies_on_purged_resources(
67
76
  port_id = ports[vm_port["name"]].id
68
77
  vm.requires.add(port_id)
69
78
 
79
+ # When a security group is purged, every port referencing it must be removed (or have the
80
+ # reference dropped) before the group itself can be deleted.
81
+ for port in ports.values():
82
+ for sg_name in port.security_groups:
83
+ if sg_name in sgs:
84
+ sgs[sg_name].requires.add(port.id)
70
85
 
71
- def set_dependencies_on_non_purged_resources(
72
- collector: dict[str, OpenstackResource],
73
- ) -> None:
86
+
87
+ def set_dependencies_on_non_purged_resources(collector: ResourceCollector) -> None:
74
88
  projects = collector.get("openstack::Project", {})
75
89
  networks = collector.get("openstack::Network", {})
76
90
  routers = collector.get("openstack::Router", {})
@@ -131,6 +145,10 @@ def set_dependencies_on_non_purged_resources(
131
145
  if port.host in vms:
132
146
  port.requires.add(vms[port.host].id)
133
147
 
148
+ for sg_name in port.security_groups:
149
+ if sg_name in sgs:
150
+ port.requires.add(sgs[sg_name].id)
151
+
134
152
  for fip in fips.values():
135
153
  if fip.external_network in networks:
136
154
  fip.requires.add(networks[fip.external_network].id)
@@ -146,7 +164,9 @@ def set_dependencies_on_non_purged_resources(
146
164
 
147
165
 
148
166
  @dependency_manager
149
- def openstack_dependencies(config_model, resource_model):
167
+ def openstack_dependencies(
168
+ config_model: object, resource_model: Mapping[object, OpenstackResource]
169
+ ) -> None:
150
170
  purged_resources, non_purged_resources = resource_collector(resource_model)
151
171
  for project, collector in non_purged_resources.items():
152
172
  set_dependencies_on_non_purged_resources(collector)
@@ -155,10 +175,12 @@ def openstack_dependencies(config_model, resource_model):
155
175
 
156
176
 
157
177
  @dependency_manager
158
- def keystone_dependencies(config_model, resource_model):
159
- projects = {}
160
- users = {}
161
- roles = []
178
+ def keystone_dependencies(
179
+ config_model: object, resource_model: Mapping[object, OpenstackResource]
180
+ ) -> None:
181
+ projects: dict[str, OpenstackResource] = {}
182
+ users: dict[str, OpenstackResource] = {}
183
+ roles: list[OpenstackResource] = []
162
184
  for _, res in resource_model.items():
163
185
  if res.id.entity_type == "openstack::Project":
164
186
  if not res.purged:
@@ -79,6 +79,29 @@ class FlavorHandler(OpenStackHandler):
79
79
  )
80
80
  return self.require_single_item(flavors)
81
81
 
82
+ def _create_flavor(self, resource: Flavor):
83
+ """Create a flavor in OpenStack."""
84
+ flavor = self._connection.compute.create_flavor(
85
+ name=resource.name,
86
+ ram=resource.ram,
87
+ vcpus=resource.vcpus,
88
+ disk=resource.disk,
89
+ id=resource.flavor_id,
90
+ ephemeral=resource.ephemeral,
91
+ swap=resource.swap,
92
+ rxtx_factor=resource.rxtx_factor,
93
+ is_public=resource.is_public,
94
+ )
95
+
96
+ if resource.extra_specs:
97
+ self._connection.compute.create_flavor_extra_specs(
98
+ flavor, resource.extra_specs
99
+ )
100
+ return flavor
101
+
102
+ def _delete_flavor(self) -> None:
103
+ self._connection.compute.delete_flavor(self.flavor.id, ignore_missing=True)
104
+
82
105
  def read_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
83
106
  self.flavor = self._get_flavor(resource)
84
107
  if self.flavor is None:
@@ -101,27 +124,12 @@ class FlavorHandler(OpenStackHandler):
101
124
  self._set_facts(ctx)
102
125
 
103
126
  def create_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
104
- self.flavor = self._connection.compute.create_flavor(
105
- name=resource.name,
106
- ram=resource.ram,
107
- vcpus=resource.vcpus,
108
- disk=resource.disk,
109
- id=resource.flavor_id,
110
- ephemeral=resource.ephemeral,
111
- swap=resource.swap,
112
- rxtx_factor=resource.rxtx_factor,
113
- is_public=resource.is_public,
114
- )
115
-
116
- if resource.extra_specs:
117
- self._connection.compute.create_flavor_extra_specs(
118
- self.flavor, resource.extra_specs
119
- )
127
+ self.flavor = self._create_flavor(resource)
120
128
  self._set_facts(ctx)
121
129
  ctx.set_created()
122
130
 
123
131
  def delete_resource(self, ctx: handler.HandlerContext, resource: Flavor) -> None:
124
- self._connection.compute.delete_flavor(self.flavor.id, ignore_missing=True)
132
+ self._delete_flavor()
125
133
  ctx.set_purged()
126
134
 
127
135
  def update_resource(
@@ -130,31 +138,33 @@ class FlavorHandler(OpenStackHandler):
130
138
  changes: dict,
131
139
  resource: Flavor,
132
140
  ) -> None:
133
- self.validate_allowed_fields(
134
- changes,
135
- {
136
- "extra_specs",
137
- },
138
- )
139
-
140
- if "extra_specs" in changes:
141
- new_extra_specs = changes["extra_specs"]["desired"]
142
- extra_specs_to_remove = [
143
- key for key in self.flavor.extra_specs if key not in new_extra_specs
144
- ]
145
- for key in extra_specs_to_remove:
146
- self._connection.compute.delete_flavor_extra_specs_property(
147
- self.flavor, key
148
- )
149
- self._connection.compute.create_flavor_extra_specs(
150
- self.flavor, new_extra_specs
141
+ """Update flavor parameters.
142
+
143
+ OpenStack flavors must be immutable, so *every* change - including
144
+ extra_specs - is applied by deleting the existing flavor and
145
+ recreating it. This is only safe when OpenStack assigns the id: an
146
+ explicit flavor_id would collide with the soft-deleted flavor, so we
147
+ refuse such changes instead.
148
+ """
149
+ if self._resource_has_explicit_flavor_id(resource):
150
+ raise ValueError(
151
+ f"Cannot change {sorted(changes)} on a flavor with an explicit "
152
+ f"flavor_id: recreating it would reuse the soft-deleted id. "
153
+ f"Remove the flavor and create a new one instead."
151
154
  )
152
- self.flavor = self._get_flavor(resource)
155
+
156
+ ctx.info(
157
+ "Recreating flavor; changed field(s): %(fields)s",
158
+ fields=sorted(changes),
159
+ )
160
+ self._delete_flavor()
161
+ self.flavor = self._create_flavor(resource)
162
+ self._set_facts(ctx)
153
163
  ctx.set_updated()
154
164
 
155
165
  def _set_facts(self, ctx):
156
- ctx.set_fact("flavor_id", self.flavor.id, expires=False)
157
- ctx.set_fact("is_disabled", str(self.flavor.is_disabled), expires=True)
166
+ ctx.set_fact("flavor_id", self.flavor.id)
167
+ ctx.set_fact("is_disabled", str(self.flavor.is_disabled))
158
168
 
159
169
  @cache(timeout=5)
160
170
  def facts(self, ctx, resource: Flavor) -> dict:
@@ -0,0 +1,117 @@
1
+ """
2
+ Copyright 2026 Inmanta
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Contact: code@inmanta.com
17
+ """
18
+
19
+ from inmanta.agent import handler
20
+ from inmanta.agent.handler import ResourcePurged, SkipResource, provider
21
+ from inmanta.resources import resource
22
+
23
+ from inmanta_plugins.openstack.common.base import (
24
+ OpenstackAdminResource,
25
+ OpenStackHandler,
26
+ )
27
+
28
+
29
+ @resource(
30
+ "openstack::HostAggregateMetadata",
31
+ agent="provider.name",
32
+ id_attribute="metadata_id",
33
+ )
34
+ class HostAggregateMetadata(OpenstackAdminResource):
35
+ """A single key/value metadata item on a Nova host aggregate."""
36
+
37
+ fields = ("aggregate", "key", "value")
38
+ aggregate: str
39
+ key: str
40
+ value: str
41
+
42
+ @staticmethod
43
+ def get_metadata_id(_, resource) -> str:
44
+ # Unique per (aggregate, key) within the provider's agent.
45
+ return f"{resource.aggregate}::{resource.key}"
46
+
47
+
48
+ @provider("openstack::HostAggregateMetadata", name="openstack")
49
+ class HostAggregateMetadataHandler(OpenStackHandler):
50
+ """Manage a single metadata key on a host aggregate.
51
+
52
+ create/update -> set the key to the desired value
53
+ delete -> remove the key (Nova removes a key when its value is None),
54
+ leaving any other keys on the aggregate untouched
55
+ """
56
+
57
+ def _get_aggregate(self, resource: HostAggregateMetadata, *, required: bool):
58
+ aggregate = self._connection.compute.find_aggregate(
59
+ resource.aggregate, ignore_missing=True
60
+ )
61
+ if aggregate is None and required:
62
+ raise SkipResource(
63
+ f"Host aggregate '{resource.aggregate}' does not exist; cannot manage "
64
+ f"metadata key '{resource.key}'. The aggregate must be created first."
65
+ )
66
+ return aggregate
67
+
68
+ def read_resource(
69
+ self, ctx: handler.HandlerContext, resource: HostAggregateMetadata
70
+ ) -> None:
71
+ aggregate = self._get_aggregate(resource, required=True)
72
+ metadata = aggregate.metadata or {}
73
+
74
+ if resource.key not in metadata:
75
+ raise ResourcePurged()
76
+
77
+ resource.purged = False
78
+ # Surface the current value so a changed value is detected as a diff.
79
+ resource.value = metadata[resource.key]
80
+
81
+ def create_resource(
82
+ self, ctx: handler.HandlerContext, resource: HostAggregateMetadata
83
+ ) -> None:
84
+ aggregate = self._get_aggregate(resource, required=True)
85
+ self._connection.compute.set_aggregate_metadata(
86
+ aggregate, metadata={resource.key: resource.value}
87
+ )
88
+ ctx.set_created()
89
+
90
+ def update_resource(
91
+ self,
92
+ ctx: handler.HandlerContext,
93
+ changes: dict,
94
+ resource: HostAggregateMetadata,
95
+ ) -> None:
96
+ # Only the value can change; aggregate and key are part of the resource id.
97
+ aggregate = self._get_aggregate(resource, required=True)
98
+ self._connection.compute.set_aggregate_metadata(
99
+ aggregate, metadata={resource.key: resource.value}
100
+ )
101
+ ctx.set_updated()
102
+
103
+ def delete_resource(
104
+ self, ctx: handler.HandlerContext, resource: HostAggregateMetadata
105
+ ) -> None:
106
+ aggregate = self._get_aggregate(resource, required=False)
107
+ if aggregate is None:
108
+ # Aggregate gone -> the key is gone with it; nothing to unset.
109
+ ctx.set_purged()
110
+ return
111
+
112
+ # Nova removes a metadata key when it is set to None, leaving any other keys
113
+ # on the aggregate untouched.
114
+ self._connection.compute.set_aggregate_metadata(
115
+ aggregate, metadata={resource.key: None}
116
+ )
117
+ ctx.set_purged()
@@ -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 in a router
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
- ctx.info(
117
- "VM for port is not in active state, but %(state)s. Waiting and retrying in 5 seconds.",
118
- state=vm_state,
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
- if resource.address and self.port.fixed_ips:
163
- resource.address = self.port.fixed_ips[0]["ip_address"]
164
-
165
- if self.port.fixed_ips:
166
- subnet_id = self.port.fixed_ips[0]["subnet_id"]
167
- subnet = self.get_subnet(None, subnet_id=subnet_id)
168
- if subnet is None:
169
- ctx.warning(
170
- f"Unable to find the name of the subnet with id {subnet_id} "
171
- f"for port {self.port.id} with ip {self.port.fixed_ips[0]['ip_address']}"
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 resource.address and not resource.dhcp:
207
- port_attrs["fixed_ips"] = [
208
- {"subnet_id": subnet.id, "ip_address": resource.address}
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
- port_attrs["security_groups"] = None
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, {"portsecurity", "name", "allowed_address_pairs"}
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.security_groups = None
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 _set_facts(self, ctx):
279
- for i, ip in enumerate(self.port.fixed_ips):
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