inmanta-module-openstack 5.1.0__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.1.0 → inmanta_module_openstack-5.1.1}/PKG-INFO +1 -1
  2. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/PKG-INFO +1 -1
  3. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/SOURCES.txt +1 -0
  4. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/host_port.py +88 -30
  5. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/virtual_machine.py +161 -29
  6. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/model/_init.cf +27 -4
  7. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/network_port.py +15 -1
  8. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/setup.cfg +1 -1
  9. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/setup.cfg +1 -1
  10. inmanta_module_openstack-5.1.1/tests/test_host_port_fixed_ips.py +347 -0
  11. inmanta_module_openstack-5.1.1/tests/test_nova.py +570 -0
  12. inmanta_module_openstack-5.1.0/tests/test_nova.py +0 -287
  13. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/MANIFEST.in +0 -0
  14. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/README.md +0 -0
  15. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/dependency_links.txt +0 -0
  16. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/not-zip-safe +0 -0
  17. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/requires.txt +0 -0
  18. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_module_openstack.egg-info/top_level.txt +0 -0
  19. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/__init__.py +0 -0
  20. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/__init__.py +0 -0
  21. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/base.py +0 -0
  22. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/common/deps.py +0 -0
  23. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/__init__.py +0 -0
  24. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/flavor.py +0 -0
  25. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/compute/host_aggregate.py +0 -0
  26. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/__init__.py +0 -0
  27. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/group.py +0 -0
  28. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/group_role.py +0 -0
  29. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/keystone_base.py +0 -0
  30. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/project.py +0 -0
  31. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/role.py +0 -0
  32. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/identity/user.py +0 -0
  33. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/image/__init__.py +0 -0
  34. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/image/image.py +0 -0
  35. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/__init__.py +0 -0
  36. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/floating_ip.py +0 -0
  37. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/network.py +0 -0
  38. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/qos_policy.py +0 -0
  39. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/router.py +0 -0
  40. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/router_port.py +0 -0
  41. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/security_group.py +0 -0
  42. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/subnet.py +0 -0
  43. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/subnet_v6.py +0 -0
  44. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/trunk_network.py +0 -0
  45. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/network/trunk_port.py +0 -0
  46. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/quota/__init__.py +0 -0
  47. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/inmanta_plugins/openstack/quota/quota.py +0 -0
  48. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/pyproject.toml +0 -0
  49. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_block_devices.py +0 -0
  50. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_dependency_handling.py +0 -0
  51. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_examples.py +0 -0
  52. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_flavor.py +0 -0
  53. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_host_aggregate.py +0 -0
  54. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_host_port_security_groups.py +0 -0
  55. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_image.py +0 -0
  56. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_keystone.py +0 -0
  57. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_neutron.py +0 -0
  58. {inmanta_module_openstack-5.1.0 → inmanta_module_openstack-5.1.1}/tests/test_quota.py +0 -0
  59. {inmanta_module_openstack-5.1.0 → 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.1.0
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.1.0
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
@@ -47,6 +47,7 @@ tests/test_dependency_handling.py
47
47
  tests/test_examples.py
48
48
  tests/test_flavor.py
49
49
  tests/test_host_aggregate.py
50
+ tests/test_host_port_fixed_ips.py
50
51
  tests/test_host_port_security_groups.py
51
52
  tests/test_image.py
52
53
  tests/test_keystone.py
@@ -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 = (
@@ -48,6 +50,7 @@ class HostPort(Port):
48
50
  "allowed_address_pairs",
49
51
  "wait_for_vm",
50
52
  "security_groups",
53
+ "address_v6",
51
54
  )
52
55
  host: str
53
56
  portsecurity: bool
@@ -58,6 +61,21 @@ class HostPort(Port):
58
61
  allowed_address_pairs: dict[str, Optional[str]]
59
62
  wait_for_vm: bool
60
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
61
79
 
62
80
  @staticmethod
63
81
  def get_host(_, port):
@@ -86,6 +104,11 @@ class HostPort(Port):
86
104
 
87
105
  @provider("openstack::HostPort", name="openstack")
88
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
+
89
112
  def _get_port(self, ctx, network_id, device_id):
90
113
  """
91
114
  Retrieve a port attached to a given device (VM) on a specific network.
@@ -119,10 +142,13 @@ class HostPortHandler(OpenStackHandler):
119
142
  if vm_state == "active":
120
143
  return vm
121
144
  else:
122
- ctx.info(
123
- "VM for port is not in active state, but %(state)s. Waiting and retrying in 5 seconds.",
124
- state=vm_state,
125
- )
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()
126
152
  else:
127
153
  if not resource.wait_for_vm:
128
154
  # Vm doesn't exist, so we can assume this port doesn't exist either
@@ -165,21 +191,17 @@ class HostPortHandler(OpenStackHandler):
165
191
  raise ResourcePurged()
166
192
 
167
193
  resource.purged = False
168
- if resource.address and self.port.fixed_ips:
169
- resource.address = self.port.fixed_ips[0]["ip_address"]
170
-
171
- if self.port.fixed_ips:
172
- subnet_id = self.port.fixed_ips[0]["subnet_id"]
173
- subnet = self.get_subnet(None, subnet_id=subnet_id)
174
- if subnet is None:
175
- ctx.warning(
176
- f"Unable to find the name of the subnet with id {subnet_id} "
177
- f"for port {self.port.id} with ip {self.port.fixed_ips[0]['ip_address']}"
178
- )
179
- else:
180
- resource.subnet = subnet.name
181
- else:
182
- 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
183
205
 
184
206
  resource.portsecurity = self.port.is_port_security_enabled
185
207
 
@@ -216,10 +238,17 @@ class HostPortHandler(OpenStackHandler):
216
238
  "project_id": self.project_id,
217
239
  }
218
240
 
219
- if resource.address and not resource.dhcp:
220
- port_attrs["fixed_ips"] = [
221
- {"subnet_id": subnet.id, "ip_address": resource.address}
222
- ]
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
223
252
 
224
253
  port_attrs["port_security_enabled"] = resource.portsecurity
225
254
  if resource.security_groups:
@@ -268,7 +297,13 @@ class HostPortHandler(OpenStackHandler):
268
297
  ) -> None:
269
298
  self.validate_allowed_fields(
270
299
  changes,
271
- {"portsecurity", "name", "allowed_address_pairs", "security_groups"},
300
+ {
301
+ "portsecurity",
302
+ "name",
303
+ "allowed_address_pairs",
304
+ "security_groups",
305
+ "address_v6",
306
+ },
272
307
  )
273
308
 
274
309
  disabling_portsecurity = (
@@ -299,18 +334,41 @@ class HostPortHandler(OpenStackHandler):
299
334
  ]
300
335
  self.port.allowed_address_pairs = allowed_address_pairs
301
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
+
302
346
  try:
303
347
  self.port = self._connection.network.update_port(self.port)
304
348
  except openstack.exceptions.ConflictException as e:
305
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)
306
353
  ctx.set_updated()
307
354
 
308
- def _set_facts(self, ctx):
309
- for i, ip in enumerate(self.port.fixed_ips):
310
- if i > 1:
311
- # report only additional ip addresses not present in model
312
- 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.
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
+ )
313
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)
314
372
  ctx.set_fact("mac_address", self.port.mac_address, expires=True)
315
373
  ctx.set_fact("port_id", self.port.id, expires=False)
316
374
 
@@ -20,6 +20,7 @@ import time
20
20
  import traceback
21
21
  from typing import Any
22
22
 
23
+ from inmanta import ast
23
24
  from inmanta.agent.handler import (
24
25
  HandlerContext,
25
26
  ResourcePurged,
@@ -29,7 +30,7 @@ from inmanta.agent.handler import (
29
30
  )
30
31
  from inmanta.execute import proxy
31
32
  from inmanta.resources import resource
32
- from openstack.exceptions import ResourceFailure, ResourceTimeout
33
+ from openstack.exceptions import NotFoundException, ResourceFailure, ResourceTimeout
33
34
 
34
35
  from inmanta_plugins.openstack.common.base import (
35
36
  NotUniqueException,
@@ -47,6 +48,7 @@ class VirtualMachine(OpenstackResource):
47
48
  fields = (
48
49
  "name",
49
50
  "flavor",
51
+ "flavor_spec",
50
52
  "image",
51
53
  "key_name",
52
54
  "user_data",
@@ -61,6 +63,7 @@ class VirtualMachine(OpenstackResource):
61
63
  )
62
64
  name: str
63
65
  flavor: str
66
+ flavor_spec: dict[str, int] | None
64
67
  image: str
65
68
  key_name: str
66
69
  user_data: str
@@ -73,6 +76,25 @@ class VirtualMachine(OpenstackResource):
73
76
  availability_zone: str | None
74
77
  block_devices: list[dict]
75
78
 
79
+ @staticmethod
80
+ def get_flavor_spec(_, vm):
81
+ """Resources of the linked flavor, used to detect a redefined flavor.
82
+
83
+ Returns None when no flavor relation is set; in that case the handler
84
+ only compares flavor names and does not resize on a content change.
85
+ """
86
+ try:
87
+ flavor = vm.flavor_rel
88
+ except ast.OptionalValueException:
89
+ return None
90
+ return {
91
+ "ram": flavor.ram,
92
+ "vcpus": flavor.vcpus,
93
+ "disk": flavor.disk,
94
+ "ephemeral": flavor.ephemeral,
95
+ "swap": flavor.swap,
96
+ }
97
+
76
98
  @staticmethod
77
99
  def get_key_name(exporter, vm):
78
100
  return vm.key_pair.name
@@ -100,13 +122,18 @@ class VirtualMachine(OpenstackResource):
100
122
  port = {
101
123
  "name": p.name,
102
124
  "address": None,
125
+ "address_v6": None,
103
126
  "network": p.subnet.name,
104
127
  "dhcp": p.dhcp,
105
128
  "index": p.port_index,
106
129
  }
107
130
  try:
108
131
  port["address"] = p.address
109
- except proxy.UnknownException:
132
+ except (proxy.UnknownException, ast.OptionalValueException):
133
+ pass
134
+ try:
135
+ port["address_v6"] = p.address_v6
136
+ except (proxy.UnknownException, ast.OptionalValueException):
110
137
  pass
111
138
  ports.append(port)
112
139
 
@@ -176,8 +203,13 @@ class VirtualMachineHandler(OpenStackHandler):
176
203
  if network is None:
177
204
  raise SkipResource(f"Network {port['network']} not found")
178
205
  nic = {"uuid": network}
206
+ # A boot NIC carries a single fixed_ip. A dual-stack port boots with its
207
+ # IPv4 here (preserving its port_index slot); the HostPort handler adds the
208
+ # IPv6 to the same port afterwards via update_port, which keeps the slot.
179
209
  if port["address"] is not None:
180
210
  nic["fixed_ip"] = port["address"]
211
+ elif port["address_v6"] is not None:
212
+ nic["fixed_ip"] = port["address_v6"]
181
213
  else:
182
214
  nic["port"] = port_id
183
215
 
@@ -245,29 +277,111 @@ class VirtualMachineHandler(OpenStackHandler):
245
277
  f"Server {self.server.id} ({self.server.name}) is in ERROR state: {msg}"
246
278
  ) from None
247
279
 
248
- def _wait_for_active(self, ctx: HandlerContext, timeout: int = 600) -> None:
249
- """Wait for self.server to reach ACTIVE, raise on ERROR or timeout."""
280
+ def _wait_for_status(
281
+ self,
282
+ ctx: HandlerContext,
283
+ status: str,
284
+ operation: str,
285
+ timeout: int = 600,
286
+ ) -> None:
287
+ """Wait for self.server to reach `status`, raise on ERROR or timeout.
288
+
289
+ `operation` describes the action in progress (e.g. "build" or "resize")
290
+ and is only used to build the error messages.
291
+
292
+ Nova may briefly return a 404 for the instance while a resize is being
293
+ applied; such transient NotFoundException errors are tolerated and polling
294
+ resumes until the status is reached or the timeout elapses.
295
+ """
296
+ deadline = time.monotonic() + timeout
297
+ while True:
298
+ remaining = deadline - time.monotonic()
299
+ if remaining <= 0:
300
+ self._raise_status_timeout(ctx, status, timeout)
301
+ try:
302
+ self.server = self._connection.compute.wait_for_server(
303
+ self.server, status=status, wait=max(1, int(remaining))
304
+ )
305
+ return
306
+ except ResourceFailure:
307
+ self._raise_server_error(
308
+ ctx, f"VM entered ERROR state during {operation}"
309
+ )
310
+ except ResourceTimeout:
311
+ self._raise_status_timeout(ctx, status, timeout)
312
+ except NotFoundException:
313
+ # The instance is temporarily not retrievable (e.g. mid-resize);
314
+ # give it a moment and keep polling until the deadline.
315
+ time.sleep(2)
316
+
317
+ def _raise_status_timeout(
318
+ self, ctx: HandlerContext, status: str, timeout: int
319
+ ) -> None:
250
320
  try:
251
- self.server = self._connection.compute.wait_for_server(
252
- self.server, wait=timeout
253
- )
254
- except ResourceFailure:
255
- self._raise_server_error(ctx, "VM entered ERROR state during build")
256
- except ResourceTimeout:
257
321
  server = self._connection.compute.get_server(self.server.id)
258
- status = getattr(server, "status", "unknown")
322
+ current_status = getattr(server, "status", "unknown")
259
323
  task_state = getattr(server, "task_state", "unknown")
260
- ctx.error(
261
- "Timed out waiting for VM to become ACTIVE",
262
- timeout_seconds=timeout,
263
- current_status=status,
264
- current_task_state=task_state,
265
- server_id=self.server.id,
266
- )
267
- raise Exception(
268
- f"Server {self.server.id} did not reach ACTIVE within {timeout}s "
269
- f"(status={status}, task_state={task_state})"
270
- ) from None
324
+ except NotFoundException:
325
+ current_status = "not_found"
326
+ task_state = "unknown"
327
+ ctx.error(
328
+ f"Timed out waiting for VM to reach {status}",
329
+ timeout_seconds=timeout,
330
+ current_status=current_status,
331
+ current_task_state=task_state,
332
+ server_id=self.server.id,
333
+ )
334
+ raise Exception(
335
+ f"Server {self.server.id} did not reach {status} within {timeout}s "
336
+ f"(status={current_status}, task_state={task_state})"
337
+ ) from None
338
+
339
+ def _get_server_flavor_name(self, server) -> str:
340
+ """Return the flavor name of `server`.
341
+
342
+ Depending on the negotiated Nova microversion the flavor embedded in a
343
+ server may only carry its id, so fall back to resolving the flavor by id.
344
+ """
345
+ flavor = server.flavor
346
+ if flavor.original_name:
347
+ return flavor.original_name
348
+ return self._connection.compute.get_flavor(flavor.id).name
349
+
350
+ def _get_server_flavor_spec(self, server) -> dict[str, int]:
351
+ """Return the resources `server` is actually running with.
352
+
353
+ Mirrors _get_server_flavor_name: recent Nova microversions embed the full
354
+ flavor spec in the server, older ones only its id, so resolve by id then.
355
+ The keys match those exported by VirtualMachine.get_flavor_spec.
356
+ """
357
+ flavor = server.flavor
358
+ if not flavor.original_name:
359
+ flavor = self._connection.compute.get_flavor(flavor.id)
360
+ return {
361
+ "ram": flavor.ram,
362
+ "vcpus": flavor.vcpus,
363
+ "disk": flavor.disk,
364
+ "ephemeral": getattr(flavor, "ephemeral", 0) or 0,
365
+ "swap": getattr(flavor, "swap", 0) or 0,
366
+ }
367
+
368
+ def _resize_server(
369
+ self, ctx: HandlerContext, resource: VirtualMachine, flavor_name: str
370
+ ) -> None:
371
+ """Resize self.server to the flavor named `flavor_name` and confirm it."""
372
+ flavor = self._connection.compute.find_flavor(flavor_name, ignore_missing=False)
373
+ # Confirming a resize returns the server to the status it had before,
374
+ # so remember it to know what to wait for afterwards.
375
+ original_status = self.server.status
376
+ ctx.info(
377
+ f"Resizing server {resource.name} to flavor {flavor_name}",
378
+ flavor_id=flavor.id,
379
+ )
380
+ self._connection.compute.resize_server(self.server, flavor.id)
381
+ # A resize must be explicitly confirmed once the server reports VERIFY_RESIZE.
382
+ self._wait_for_status(ctx, "VERIFY_RESIZE", "resize")
383
+ self._connection.compute.confirm_server_resize(self.server)
384
+ self._wait_for_status(ctx, original_status, "resize")
271
385
 
272
386
  def read_resource(self, ctx: HandlerContext, resource: VirtualMachine) -> None:
273
387
  """
@@ -278,12 +392,16 @@ class VirtualMachineHandler(OpenStackHandler):
278
392
  if self.server is None:
279
393
  raise ResourcePurged()
280
394
 
281
- if self.server.status == "ERROR":
282
- self._raise_server_error(
283
- ctx, "Server is in ERROR state; delete it to allow redeployment"
284
- )
285
-
286
395
  resource.purged = False
396
+ # Reflect the live flavor so a flavor change in the model is detected as
397
+ # drift and handled by update_resource through a resize.
398
+ resource.flavor = self._get_server_flavor_name(self.server)
399
+ # When the VM is linked to a managed flavor, also reflect the resources it
400
+ # is currently running with. This makes a flavor that was redefined under
401
+ # the same name (e.g. deleted and recreated with more RAM) show up as drift
402
+ # even though its name is unchanged.
403
+ if resource.flavor_spec is not None:
404
+ resource.flavor_spec = self._get_server_flavor_spec(self.server)
287
405
  if resource.security_groups:
288
406
  # An empty relation leaves the server-level security groups unmanaged, so port
289
407
  # security groups (managed through neutron) can be used for fine-grained control
@@ -335,7 +453,7 @@ class VirtualMachineHandler(OpenStackHandler):
335
453
  )
336
454
 
337
455
  self.server = self._connection.compute.create_server(**args)
338
- self._wait_for_active(ctx)
456
+ self._wait_for_status(ctx, "ACTIVE", "build")
339
457
 
340
458
  self._set_facts(ctx)
341
459
  # Note: attach/detach of interfaces handled by Neutron or by passing ports at creation time
@@ -365,9 +483,23 @@ class VirtualMachineHandler(OpenStackHandler):
365
483
  def update_resource(
366
484
  self, ctx: HandlerContext, changes: dict, resource: VirtualMachine
367
485
  ) -> None:
368
- self.validate_allowed_fields(changes, {"security_groups", "key_name"})
486
+
487
+ if self.server.status == "ERROR":
488
+ self._raise_server_error(
489
+ ctx, "Server is in ERROR state; delete it to allow redeployment"
490
+ )
491
+
492
+ self.validate_allowed_fields(
493
+ changes, {"security_groups", "key_name", "flavor", "flavor_spec"}
494
+ )
369
495
 
370
496
  self._ensure_key(ctx, resource)
497
+
498
+ # A change to either the flavor name or, for a flavor-linked VM, the flavor
499
+ # resources both translate to an in-place resize to the desired flavor.
500
+ if "flavor" in changes or "flavor_spec" in changes:
501
+ self._resize_server(ctx, resource, resource.flavor)
502
+
371
503
  if "security_groups" in changes:
372
504
  current = set(changes["security_groups"]["current"])
373
505
  desired = set(changes["security_groups"]["desired"])
@@ -350,6 +350,10 @@ entity HostPort extends Port:
350
350
  A port attached to a VM
351
351
 
352
352
  :param name: The name of the host port.
353
+ :param address: An optional fixed IPv4 to request. When omitted neutron assigns one; leave unset
354
+ for an IPv6-only port. Ignored when dhcp is true.
355
+ :param address_v6: An optional fixed IPv6 to request from an IPv6 subnet on the port's network.
356
+ When omitted no IPv6 is requested. Ignored when dhcp is true.
353
357
  :param portsecurity: Enable or disable port security (security groups and spoofing filters)
354
358
  :param dhcp: Enable dhcp for this port or not for this port
355
359
  :param port_index: The index of the port. This determines the order of the interfaces on the virtual machine. 0 means no specific order.
@@ -357,7 +361,8 @@ entity HostPort extends Port:
357
361
  speed up deployments, the handler can retry this number of times before skipping the resource.
358
362
  :param wait: The number of seconds to wait between retries.
359
363
  """
360
- std::ipv4_address address
364
+ std::ipv4_address? address = null
365
+ std::ipv6_address? address_v6 = null
361
366
  bool portsecurity=true
362
367
  bool dhcp=true
363
368
  int port_index=0
@@ -376,7 +381,7 @@ end
376
381
  implement HostPort using providerRequire
377
382
  implement HostPort using noSecurityGroupsWhenPortSecurityDisabled when not self.portsecurity
378
383
 
379
- HostPort.subnet [1] -- Subnet.host_ports [0:]
384
+ HostPort.subnet [1] -- SubnetBase.host_ports [0:]
380
385
  HostPort.vm [1] -- VirtualMachine.ports [0:]
381
386
 
382
387
  index HostPort(provider, vm, name)
@@ -510,9 +515,16 @@ entity NetworkPort extends Port:
510
515
  :attr portsecurity: Enable or disable port security (security groups and spoofing filters).
511
516
  :attr address: An optional fixed ip address for the port. When omitted, neutron
512
517
  assigns one from the network. When set, a subnet must be provided.
518
+ :attr mac_address: An optional MAC address for the port. When omitted, neutron
519
+ assigns one. It is typed as a plain string (not the ``mac_addr``
520
+ typedef) so it can hold a fact reference resolved at deploy time,
521
+ e.g. ``future::std::create_fact_reference(parent_port, "mac_address")``
522
+ to make a trunk sub-port share its parent port's MAC. Set only at
523
+ creation; neutron does not allow changing it afterwards.
513
524
  """
514
525
  bool portsecurity=true
515
526
  std::ipv4_address? address=null
527
+ string? mac_address=null
516
528
  end
517
529
 
518
530
  index NetworkPort(project, name)
@@ -713,14 +725,25 @@ entity VirtualMachine extends OpenStackResource, VMAttributes:
713
725
  string name
714
726
  end
715
727
 
716
- implement VirtualMachine using providerRequire
717
-
718
728
  index VirtualMachine(provider, name)
719
729
 
720
730
  VirtualMachine.eth0_port [1] -- HostPort
721
731
  VirtualMachine.key_pair [1] -- ssh::Key
722
732
  VirtualMachine.project [1] -- Project
723
733
  VirtualMachine.provider [1] -- Provider.virtual_machines [0:]
734
+ # Optional link to the managed Flavor backing this VM. When set, the flavor name
735
+ # is derived from it and the handler compares the flavor's resources against what
736
+ # the VM is actually running, so a flavor recreated with different resources under
737
+ # the same name is detected as drift and applied as an in-place resize.
738
+ VirtualMachine.flavor_rel [0:1] -- Flavor
739
+
740
+ implementation flavorFromRelation for VirtualMachine:
741
+ self.flavor = self.flavor_rel.name
742
+ self.requires += self.flavor_rel
743
+ end
744
+
745
+ implement VirtualMachine using providerRequire
746
+ implement VirtualMachine using flavorFromRelation when flavor_rel is defined
724
747
 
725
748
  entity Host extends std::Host, VMAttributes:
726
749
  """
@@ -35,12 +35,13 @@ class NetworkPort(OpenstackResource):
35
35
  A standalone neutron port, not bound to a router or a virtual machine
36
36
  """
37
37
 
38
- fields = ("name", "portsecurity", "address", "network", "subnet")
38
+ fields = ("name", "portsecurity", "address", "network", "subnet", "mac_address")
39
39
  name: str
40
40
  portsecurity: bool
41
41
  address: Optional[str]
42
42
  network: str
43
43
  subnet: Optional[str]
44
+ mac_address: Optional[str]
44
45
 
45
46
  @staticmethod
46
47
  def get_network(_, port):
@@ -96,6 +97,14 @@ class NetworkPortHandler(OpenStackHandler):
96
97
  resource.address = None
97
98
  resource.subnet = None
98
99
 
100
+ if resource.mac_address:
101
+ # A MAC was requested (set only at creation, see create_resource);
102
+ # reflect what neutron has so an already-correct port shows no diff.
103
+ resource.mac_address = self.port.mac_address
104
+ else:
105
+ # No MAC is managed; leave neutron's auto-assigned MAC untouched.
106
+ resource.mac_address = None
107
+
99
108
  self._set_facts(ctx)
100
109
 
101
110
  def create_resource(
@@ -123,6 +132,11 @@ class NetworkPortHandler(OpenStackHandler):
123
132
  {"subnet_id": subnet.id, "ip_address": resource.address}
124
133
  ]
125
134
 
135
+ if resource.mac_address:
136
+ # Pin the port MAC (e.g. to a trunk parent port's MAC) so a sub-port
137
+ # sharing the parent MAC is not dropped by port security as spoofed.
138
+ port_attrs["mac_address"] = resource.mac_address
139
+
126
140
  self.port = self._connection.network.create_port(**port_attrs)
127
141
  if self.port is None:
128
142
  raise Exception("Unable to create port.")
@@ -22,7 +22,7 @@ target-version = 'py36', 'py37', 'py38'
22
22
  name = inmanta-module-openstack
23
23
  freeze_recursive = False
24
24
  freeze_operator = ~=
25
- version = 5.1.0
25
+ version = 5.1.1
26
26
  license = Apache 2.0
27
27
 
28
28
  [egg_info]
@@ -22,7 +22,7 @@ target-version = 'py36', 'py37', 'py38'
22
22
  name = inmanta-module-openstack
23
23
  freeze_recursive = False
24
24
  freeze_operator = ~=
25
- version = 5.1.0
25
+ version = 5.1.1
26
26
  license = Apache 2.0
27
27
 
28
28
  [egg_info]