57 Commits
2.5.0 ... 2.6.0

Author SHA1 Message Date
Grzegorz Koper
2419b6e30c Prepare release metadata for openstack.cloud 2.6.0
Bump galaxy.yml to 2.6.0.
Add a 2.6.0 entry in changelogs/changelog.yaml,
regenerate CHANGELOG.rst with antsibull-changelog.

Tests-Run: tox -e pep8

Change-Id: I03a4a424d1f47a9fdaa70efae9b8c9a3fa60b072
Signed-off-by: Grzegorz Koper <grzegorzk@stackhpc.com>
2026-06-10 19:37:41 +02:00
Stephen Finucane
0ada05ec10 Remove version checks
openstacksdk 1.0.0 was released 3½ years ago [1]. pip is not going to
pull that version any more. These checks are redundant.

In addition, we can remove sdk_version extra var. Change
I9665f04e6c0d5a84d6c20a73ef7b0dfdc7bd8159 removed the last guard that
relied on this, similarly about 3½ years ago.

[1] https://pypi.org/project/openstacksdk/1.0.0/

Change-Id: Ic0dd7bc5de1f24d1ee3336ccea97aced850f86af
Signed-off-by: Stephen Finucane <stephenfin@redhat.com>
2026-06-10 14:05:10 +01:00
Zuul
c81422e3f6 Merge "[doc] Document usage of changelog" 2026-05-26 08:49:50 +00:00
Zuul
2a062cb82e Merge "Add support for l7_rules loadbalancer quota" 2026-05-25 05:19:48 +00:00
Dmitriy Rabotyagov
6ec9548850 [doc] Document usage of changelog
At the moment project documentation does not contain any mention
on how changelog should be produced.

Some use reno, some use fragments. At the moment there is no transition
from reno to antsibull format is available, so let's ask contributors to
follow ansible's format of changelog fragments.

In case we come up with a transition layer from reno to fragment format,
or antsibull replacement, we can change documentation.

Change-Id: I048f80af849d2872be866e44882d2a8fcbf01cfc
Signed-off-by: Dmitriy Rabotyagov <dmitriy.rabotyagov@cleura.com>
2026-05-22 09:06:42 +02:00
Mathieu Gagné
8d8cf1d6a7 Add support for l7_rules loadbalancer quota
Change-Id: I1085bdad5f6bb6f29fb4c6fd60d991f1240739c4
Signed-off-by: Mathieu Gagné <mgagne@calavera.ca>
2026-05-21 16:04:22 +00:00
Dmitriy Rabotyagov
616f71341e Convert published release notes to fragments
In order to generate a proper changelog as a part of releasing
process, we should maintain proper fragments instead of the
notes produced by `reno`.
This patch moves content to the expected place.

Change-Id: Icedb383b08a2e7ce60f5423912c09b2c499070ad
Signed-off-by: Dmitriy Rabotyagov <dmitriy.rabotyagov@cleura.com>
2026-05-21 17:59:16 +02:00
Zuul
a4c6d4ba3a Merge "Add volume_retype module" 2026-05-21 14:00:57 +00:00
Zuul
84580110ec Merge "feat: add compute service module" 2026-05-21 05:35:35 +00:00
Patrick Pfurtscheller
42e2503ee1 feat: add compute service module
Change-Id: Ib846bda2a9b6c39e2f09cac508fa794fa9858920
Signed-off-by: Patrick Pfurtscheller <patrick@pfurtscheller.org>
2026-05-20 22:38:09 +02:00
Simon Dodsley
1f0ced6952 Add volume_retype module
Introduce openstack.cloud.volume_retype, a new module that wraps
the Cinder os-retype block storage action (POST /volumes/{id}/action)
to change the type of an existing volume.

The module supports both migration policies exposed by the Cinder API:
- 'never'     – changes volume type metadata only; requires source and
                target types to share the same storage backend.
- 'on-demand' – triggers a full data migration to the backend
                associated with the new volume type.

The module is idempotent: if the volume already carries the requested
type no API call is made and changed=false is returned. It also
honours Ansible check mode, resolving the volume and target type to
verify feasibility without mutating any state.

A wait/timeout mechanism is included. For on-demand retypes the module
polls migration_status until 'success' or 'error'. For never retypes
it polls volume status until 'available'.

The equivalent CLI command is:
  openstack volume set --type <type> --retype-policy <policy> <vol>

This closes a gap in the collection: openstack.cloud.volume handles
create/delete/update but does not expose the retype action.

Integration tests are provided covering: basic retype, idempotency,
check mode, lookup by UUID, and error handling for missing volume
and missing volume type.

Change-Id: I32b2b6303366a19baa1e3f1473b09650f6daee66
Signed-off-by: Simon Dodsley <simon@purestorage.com>
2026-05-20 11:45:30 -04:00
Zuul
338534eab2 Merge "Add host_aggregate_info module" 2026-05-20 07:23:43 +00:00
Zuul
737ca4fed7 Merge "Fix unit test helpers for ansible-core 2.19+" 2026-05-20 07:23:41 +00:00
Zuul
603b6e2d2b Merge "Ignore common python files in .gitignore" 2026-05-20 07:02:12 +00:00
Zuul
13dadf4129 Merge "Add ability to set tags in network modules" 2026-05-19 22:09:35 +00:00
Bertrand Lanson
94bb10a5ee Add host_aggregate_info module
This module allows operators to get informations on
compute aggregates in a cluster, including availability zone
informations and hosts membership.

Change-Id: I14fa8d6914072bee314efa3753633933b21e9439
Signed-off-by: Bertrand Lanson <bertrand.lanson@infomaniak.com>
2026-05-19 21:07:30 +02:00
Bertrand Lanson
7644aeaf32 Fix unit test helpers for ansible-core 2.19+
ansible-core 2.19 introduced _ANSIBLE_PROFILE alongside _ANSIBLE_ARGS.
set_module_args() in utils.py and test_baremetal_port_group.py only set
_ANSIBLE_ARGS, causing "No serialization profile was specified" errors.
Set _ANSIBLE_PROFILE to 'legacy' to match the pre-2.19 behavior.

Change-Id: I1073f347ce18f061b5fa099442a6b6aa10c42507
Signed-off-by: Bertrand Lanson <bertrand.lanson@infomaniak.com>
2026-05-19 21:07:30 +02:00
Bertrand Lanson
836d7410b2 Ignore common python files in .gitignore
Quick quality of life improvement.

Change-Id: I4f7291190f2480fe7c78cc0d3462d59578ff6242
Signed-off-by: Bertrand Lanson <bertrand.lanson@infomaniak.com>
2026-05-19 20:55:25 +02:00
Zuul
382a43c461 Merge "Add support for setting shard key on baremetal node" 2026-05-12 22:34:14 +00:00
Zuul
bcac650895 Merge "Add volume_image_metadata" 2026-05-11 22:05:26 +00:00
Zuul
c3cd0d2838 Merge "Add port_forwarding modules" 2026-05-11 22:05:24 +00:00
Zuul
143c959f7d Merge "Only clear out security groups if the parameter is set" 2026-05-11 21:11:14 +00:00
Zuul
0e6e281316 Merge "CI: Run jobs for all non-EOL Ansible versions" 2026-05-05 11:21:41 +00:00
Zuul
a02bba1152 Merge "Add ability to allocate floating IP" 2026-05-04 16:43:39 +00:00
Simon Dodsley
80557dae33 Add volume_image_metadata
This module introduces the ability to set image metadata
which is distinct from regualt volume metadata, and is
required for correct boot-from-volume behaviour.

This allows workflows such as replication and disaster
recovery to correctly preserve image provenance and
Nova boot semantics.

Change-Id: I55732fbe8fc6bd579b8542f834033650a076db76
Signed-off-by: Simon Dodsley <simon@purestorage.com>
2026-04-30 10:36:39 +01:00
Zuul
d4a77620cc Merge "fix(trunk): Always add relevant sub_ports" 2026-04-29 18:54:39 +00:00
Michal Nasiadka
b3e1cdd730 CI: Run jobs for all non-EOL Ansible versions
Switch Jammy nodesets to Noble

Change-Id: I7d18196c12d5670a5a458d64d901ffcf3b0b0af4
Signed-off-by: Michal Nasiadka <mnasiadka@gmail.com>
2026-04-29 07:18:52 +00:00
Zuul
9e7332ffff Merge "feat(images): Adds support for image import and 'uploading' status" 2026-04-29 05:11:59 +00:00
Zuul
e545283299 Merge "Add bootable volume support" 2026-04-28 20:21:40 +00:00
Zuul
885fadb31e Merge "Add authorization_ttl option for Keystone IDP." 2026-04-20 06:15:05 +00:00
Zuul
da833ac8dc Merge "Add schema_version property to federation_mapping" 2026-04-16 10:43:34 +00:00
Doug Szumski
b1932e1b06 Add support for setting shard key on baremetal node
Ironic supports setting a shard key on baremetal nodes
which can be used to scale out the Nova Compute Ironic
service. This change adds support for setting the shard
key.

Change-Id: I9694470a8ce6d964d6251bda4463f025bd4245e0
Signed-off-by: Doug Szumski <doug@stackhpc.com>
2026-04-09 15:51:50 +01:00
Nicholas Kuechler
09a4e4248d feat(images): Adds support for image import and 'uploading' status
Change-Id: Ib5023c376f7fe1f9850ac7bdacaf6dea695cce0f
Signed-off-by: Nicholas Kuechler <nicholas.kuechler@rackspace.com>
2026-03-24 11:25:52 -05:00
Zuul
ccec4d07b3 Merge "Add support for managing network segments" 2026-03-23 15:16:45 +00:00
Taavi Ansper
1387aec1db Add schema_version property to federation_mapping
Closes-Bug: #2145020
Change-Id: Id4e19dd4d63ae083280a78863a6cdecbd043ea7c
Signed-off-by: Taavi Ansper <taaviansperr@gmail.com>
2026-03-21 12:45:10 +02:00
Zuul
04b70b99da Merge "Support updating extra_specs in project module" 2026-03-19 21:41:00 +00:00
Zuul
ed4c4036af Merge "feat(images): Adds support for image ID reservation and queued images" 2026-03-19 07:37:34 +00:00
Zuul
e2ebc1c8d0 Merge "tox: Drop basepython" 2026-03-19 07:37:33 +00:00
Zuul
99eb60f7dc Merge "Add baremetal_port_group module" 2026-03-19 06:56:08 +00:00
Nicholas Kuechler
e90fd7a915 feat(images): Adds support for image ID reservation and queued images
Change-Id: I3aa319deb711eaa1ccad4f48eedb079afd801872
Signed-off-by: Nicholas Kuechler <nicholas.kuechler@rackspace.com>
2026-03-12 14:52:48 -05:00
Taavi Ansper
b3a31eb6d5 Add authorization_ttl option for Keystone IDP.
Closes-Bug: #2142395

Change-Id: Ib3fab86da2170cc6a349c06906ad27bf54ed0d5c
Signed-off-by: Taavi Ansper <taaviansperr@gmail.com>
2026-02-22 17:36:44 +02:00
Artem Goncharov
1bc4f648fb Rotate the galaxy secret
Change-Id: I4d25f3b7831c80c615e6dd967a5e5065452cdce8
Signed-off-by: Artem Goncharov <artem.goncharov@gmail.com>
2026-02-17 17:15:54 +01:00
Grzegorz Koper
1a654a9c38 Add baremetal_port_group module
Add support for managing Ironic baremetal port groups.

Include CI role coverage and unit tests for create, update, delete, and check mode behavior.

Add a reno fragment describing the new module.

Tests-Run: python -m pytest tests/unit/modules/cloud/openstack/test_baremetal_port_group.py
Change-Id: I98564fcb5b81a1dd7be1fbf5ffca364483296655
2026-02-10 14:45:35 +01:00
Jan-Philipp Litza
af4d72a3bb fix(trunk): Always add relevant sub_ports
This fixes two similar issues:
1. sub_ports weren't added when initially creating the trunk
2. sub_ports identified by ID instead of name weren't added

Closes-Bug: #2089589
Change-Id: I1342e23aafdd44eaf16f236d6d07ace41ae1d247
Signed-off-by: Jan-Philipp Litza <janphilipp@litza.de>
2026-01-30 14:09:22 +01:00
Ivan Anfimov
67b7ec5e58 tox: Drop basepython
Python 2 reached its EOL long time ago and we no longer expect any
user may attempt to run tox in Python 2.

Removing the option allows us to remove ignore_basepython_conflict.

Change-Id: I54c0581e77c924f9d98975964e4470e89fa3d954
Co-authored-by: Takashi Kajinami <kajinamit@oss.nttdata.com>
Signed-off-by: Ivan Anfimov <lazekteam@gmail.com>
2026-01-24 12:53:27 +00:00
James Hewitt
b1e4d4b714 Only clear out security groups if the parameter is set
Because security groups are associated with ports not servers, this is a tricky UX.

When creating a server, security groups are applied to any new ports created but not existing ports because they haven't been interrogated yet. When updating a server, the security groups for all attached ports are updated. Because we have an empty list as default, this means if you don't specify security groups on the server (because you're setting them on the port instead), then server creation will work fine, but update will clear out the groups from the port.

This patch changes the default groups for the server resource to be None, so that we can tell if the user doesn't specify any groups and if they don't, rely on the ports having them set instead. It improves idempotency.

Closes-Bug: #2137488
Change-Id: Iedcc9a34dc5ac847496eab19880189e4dc3c517a
Signed-off-by: James Hewitt <james.hewitt@uk.ibm.com>
2026-01-07 15:12:23 +00:00
James Hewitt
a4ed67b054 Add bootable volume support
Add support for setting volumes to be bootable on creation, as well as support for updating the bootable flag.

Closes-Bug: #2137559
Change-Id: I60bac613060551c4d6144675b1553b4fdda2d13d
Signed-off-by: James Hewitt <james.hewitt@uk.ibm.com>
2026-01-07 11:49:47 +00:00
naosuke
70128d6230 Support updating extra_specs in project module
It supports for adding extra_specs in updating project.

Change-Id: I98a73ed9367d52df82213b3b7c484ceac10acf3d
Signed-off-by: Naoki Hanakawa <naoki.hanakawa@lycorp.co.jp>
2025-12-17 10:13:52 +09:00
Riccardo Pittau
1dc367b566 Use new bifrost CI jobs names
Ironic do not support tinyipa anymore, all jobs run with DIB
based ipa ramdisks, so we updated their config and names.

Change-Id: Id7b260a0965d941d3a34eb181a068a9a5e7189ef
Signed-off-by: Riccardo Pittau <elfosardo@gmail.com>
2025-12-16 09:28:32 +01:00
Austin Jamias
86d9e2e00a Add port_forwarding modules
This adds the ability to manage floating IP port forwarding resources.

Change-Id: Ifd7cb30faf0efbd043474d2d6c23b87a55ee73de
Signed-off-by: Austin Jamias <ajamias@redhat.com>
2025-12-10 12:59:52 -05:00
Austin Jamias
f0e0388159 Add ability to allocate floating IP
Currently, you would only use this module to create a floating IP if it
doesn't exist and attempt to attach it to a Nova server. This commit
adds support for creating a standalone floating IP not attached to
anything, and optionally attaching it to a fixed IP in a fixed network.

Change-Id: Id65ce98674b6b9d93dd4cfbbdf2c5c51798fca38
Signed-off-by: Austin Jamias <ajamias@redhat.com>
2025-12-04 15:15:05 -05:00
Austin Jamias
dbc6f7d44a Add ability to set tags in network modules
This adds the `tags` parameter to the network,
subnet, and router modules.

Change-Id: I704b40c44c733a3d4ec93a95d8efcb7ecf6e2a32
Signed-off-by: Austin Jamias <ajamias@redhat.com>
2025-11-20 17:07:29 -05:00
Maksim Malchuk
a178493281 Drop duplicate lines in the Changelog
Change-Id: I0475545d8114d8731a2131eed372c7557b579e3f
Signed-off-by: Maksim Malchuk <maksim.malchuk@gmail.com>
2025-11-17 16:19:28 +03:00
Austin Jamias
b2aac80b41 Fix tox.ini linters_2_18 config
Correct a typo where linters_2_18's requirements were not being
referenced correctly.

Change-Id: I1c7a7555c9effd4f0ceb417be709059aa10d0a5e
Signed-off-by: Austin Jamias <ajamias@redhat.com>
2025-11-10 19:45:36 -05:00
Zuul
ae6e48be00 Merge "Fix Ansible errors" 2025-10-31 18:03:37 +00:00
Michal Nasiadka
e06a61f97a Fix Ansible errors
Change-Id: I826ec0b01f8cfdf78235d146c90d790c8e891cc9
Signed-off-by: Michal Nasiadka <mnasiadka@gmail.com>
2025-10-27 07:40:26 +00:00
Andrew Bonney
eef8368e6f Add support for managing network segments
Adds a module to manage Neutron network segments where the
segmentation plugin is enabled.

Segments are relatively simple and do not support modification
beyond the name/description, so most attributes are used for
initial segment creation, or filtering results in order to
perform updates.

Depends-On: https://review.opendev.org/c/openstack/ansible-collections-openstack/+/955752
Change-Id: I4647fd96aaa15460d82765365f98a18ddf2693db
2025-07-24 09:04:30 +00:00
66 changed files with 3857 additions and 307 deletions

4
.gitignore vendored
View File

@@ -4,3 +4,7 @@ ansible_collections
FILES.json
MANIFEST.json
importer_result.json
**/__pycache__
.venv
.python-version

View File

@@ -48,6 +48,7 @@
designate: true
neutron-dns: true
neutron-trunk: true
neutron-segments: true
zuul_copy_output:
'{{ devstack_log_dir }}/test_output.log': 'logs'
extensions_to_txt:
@@ -211,17 +212,42 @@
branches: master
description: |
Run openstack collections functional tests against a master devstack
using master of openstacksdk and stable 2.16 branch of ansible
using master of openstacksdk and stable 2.18 branch of ansible
required-projects:
- name: github.com/ansible/ansible
override-checkout: stable-2.18
vars:
tox_envlist: ansible_2_18
- job:
name: ansible-collections-openstack-functional-devstack-ansible-2.19
parent: ansible-collections-openstack-functional-devstack-base
branches: master
description: |
Run openstack collections functional tests against a master devstack
using master of openstacksdk and stable 2.19 branch of ansible
required-projects:
- name: github.com/ansible/ansible
override-checkout: stable-2.19
vars:
tox_envlist: ansible_2_19
- job:
name: ansible-collections-openstack-functional-devstack-ansible-2.20
parent: ansible-collections-openstack-functional-devstack-base
branches: master
description: |
Run openstack collections functional tests against a master devstack
using master of openstacksdk and stable 2.20 branch of ansible
required-projects:
- name: github.com/ansible/ansible
override-checkout: stable-2.20
vars:
tox_envlist: ansible_2_20
- job:
name: ansible-collections-openstack-functional-devstack-ansible-devel
parent: ansible-collections-openstack-functional-devstack-base
nodeset: openstack-single-node-jammy
branches: master
description: |
Run openstack collections functional tests against a master devstack
@@ -250,13 +276,13 @@
- job:
name: openstack-tox-linters-ansible-devel
parent: openstack-tox-linters-ansible
nodeset: ubuntu-jammy
nodeset: ubuntu-noble
description: |
Run openstack collections linter tests using the devel branch of ansible
# non-voting because we can't prevent ansible devel from breaking us
voting: false
vars:
python_version: '3.10'
python_version: '3.12'
bindep_profile: test py310
- job:
@@ -272,10 +298,36 @@
python_version: "3.12"
bindep_profile: test py312
- job:
name: openstack-tox-linters-ansible-2.19
parent: openstack-tox-linters-ansible
description: |
Run openstack collections linter tests using the 2.19 branch of ansible
required-projects:
- name: github.com/ansible/ansible
override-checkout: stable-2.19
vars:
tox_envlist: linters_2_19
python_version: "3.12"
bindep_profile: test py312
- job:
name: openstack-tox-linters-ansible-2.20
parent: openstack-tox-linters-ansible
description: |
Run openstack collections linter tests using the 2.20 branch of ansible
required-projects:
- name: github.com/ansible/ansible
override-checkout: stable-2.20
vars:
tox_envlist: linters_2_20
python_version: "3.12"
bindep_profile: test py312
# Cross-checks with other projects
- job:
name: bifrost-collections-src
parent: bifrost-integration-tinyipa-ubuntu-jammy
parent: bifrost-integration-on-ubuntu-noble
required-projects:
- openstack/ansible-collections-openstack
- # always use master branch when collecting parent job variants, refer to git blame for rationale.
@@ -286,7 +338,7 @@
override-checkout: master
- job:
name: bifrost-keystone-collections-src
parent: bifrost-integration-tinyipa-keystone-ubuntu-jammy
parent: bifrost-integration-keystone-on-ubuntu-noble
required-projects:
- openstack/ansible-collections-openstack
- # always use master branch when collecting parent job variants, refer to git blame for rationale.
@@ -308,16 +360,16 @@
data:
url: https://galaxy.ansible.com
token: !encrypted/pkcs1-oaep
- QJ3c5LfmM4YmqwwLKv4wK5lroWDLGeMyPkmHXhvf0ry3vGjKZvZxVpbIhFXJHXevHov/r
nvlqwmG8D5msynQKZDFg2ZwSMIQWRKfSbsSLe7A6NWI2wC+QtZSPiRiBcBcHY1QbNNW21
84cssYa1oHOA0WXpomBz1qXuPV48aKLjMnWysgFhNSx3Oog+ZOSCczyyVVuXP1lIWIO26
AtRTrEcr37K3JY9usE2PCbZKFOq/+IDPz9fbS7PtBOv7iXOHOf3AfBiJiaJe3q/ecoaaq
ejk2WTKWfvq/3rY4pU1976kUcxgcd+jj9ReFyw8edCsc1ecL0qmZFbdHmC03jEcVo4p8I
WJQ0D5wk4/u2Fu9texNuBvb62Yu3Y028Zhm5rz8Zl/ISsdaA3losn5S7C7iAH/yKlGQEI
N/1X4M0tVPaMtsIhZyyz+JMbeNyVR9ZarqbtpzRtVhjxL7KOiAQbEzAmZcBbCJ2Z5iI+P
bTp03f9Y/tZNtkohARvx1TKhv8CvsmyGkMm+r5Y8aWz3SNy8LL6bSwtGun/ifbnadHmw/
TD5/UUXHHjBGkeAu9HTtwUZ5Qdkfg92PnPgruAAuOkF1Y4RyRS9qvwhtqyHO8TwU0INRY
5MHEzeOQWemoQb/qdENp+J/Q9oMEbpFYv9TkrWkxVoKop6Str8e3FF5sxmN/SE=
- K93hOZo1B5z248H04COB1N2HCkGbFPo2EUr+0W7qFzsrdvmbsAI86Hl9bUCfEENGrwvfV
0j9CE5iO0tyqal3r6ucMhGT44MgQWL3MBeRvK89yAJpSNMU7R7rEY/zbjZMoC9YElcHEv
GEDZSA/0gQHCHpZVDlx4JMGwrnd+Nz9ha3c12BYeZS8rS/dQl7EmZ867OsozmNdG9UkkC
0vP/dkenUQNvoZOSWgZztRBlbAyI1nc5iEEw9vvpLh19HcY9+S2iAZkgSq4jOOO4wn7gE
XAZPr0HRdwS2m4Hw0Pusrg7SdC3+2O0N/fvFGnvvKXHcSgQk3rPLn6HfKzOJoPWc4WlDX
MA79jYloNBXjOaeXOoiwYzzshWK53F6Ci+3leq1cYuFyHSi2ds2mYXat7YndZSsmsk5um
hj0+Ddy9Om1uYy3nhHyZLULE7UDUmduA9EPkvdyWlcW0yZL2kXcrDTHlSp4PaJg9iKVys
0aOOo9CNMwhyXAOGiFCYF/m7Efbnp50zUQhHN9+7LeVzXZuiH98C8kNvWfE0qrkrrgQ1n
78UMqGcGpdw4ZSlWrDTbrbd4v0bRnsJ+IAWISnT5OXaeJgGZwXRuBHtTXqbjoosBeX/8w
YKb0lx7E5ZtSw7+Y6LNDGihGTmVg1nkZUWo85CxyF/RiWHuNvpkzzqXmdGS1bg=
- project:
check:
@@ -325,9 +377,13 @@
- tox-pep8
- openstack-tox-linters-ansible-devel
- openstack-tox-linters-ansible-2.18
- openstack-tox-linters-ansible-2.19
- openstack-tox-linters-ansible-2.20
- ansible-collections-openstack-functional-devstack
- ansible-collections-openstack-functional-devstack-releases
- ansible-collections-openstack-functional-devstack-ansible-2.18
- ansible-collections-openstack-functional-devstack-ansible-2.19
- ansible-collections-openstack-functional-devstack-ansible-2.20
- ansible-collections-openstack-functional-devstack-ansible-devel
- ansible-collections-openstack-functional-devstack-magnum
- ansible-collections-openstack-functional-devstack-manila
@@ -344,6 +400,8 @@
jobs:
- tox-pep8
- openstack-tox-linters-ansible-2.18
- openstack-tox-linters-ansible-2.19
- openstack-tox-linters-ansible-2.20
- ansible-collections-openstack-functional-devstack-releases
- ansible-collections-openstack-functional-devstack-magnum
- ansible-collections-openstack-functional-devstack-manila
@@ -353,9 +411,13 @@
jobs:
- openstack-tox-linters-ansible-devel
- openstack-tox-linters-ansible-2.18
- openstack-tox-linters-ansible-2.19
- openstack-tox-linters-ansible-2.20
- ansible-collections-openstack-functional-devstack
- ansible-collections-openstack-functional-devstack-releases
- ansible-collections-openstack-functional-devstack-ansible-2.18
- ansible-collections-openstack-functional-devstack-ansible-2.19
- ansible-collections-openstack-functional-devstack-ansible-2.20
- ansible-collections-openstack-functional-devstack-ansible-devel
- bifrost-collections-src
- bifrost-keystone-collections-src

View File

@@ -4,6 +4,37 @@ Ansible OpenStack Collection Release Notes
.. contents:: Topics
v2.6.0
======
Release Summary
---------------
New modules for Bare Metal port groups and Neutron network segments, plus image/project enhancements and bugfixes.
Minor Changes
-------------
- Add support for setting the shard key on a baremetal node.
- image - Add support for image ID reservation and queued image creation
- project - Support updating extra_specs
Bugfixes
--------
- Fix Ansible errors
- Fixed compatability with openstacksdk version 4.15.0
New Modules
-----------
- openstack.cloud.baremetal_port_group - Create/Delete Bare Metal port group resources from OpenStack
- openstack.cloud.compute_service - Update OpenStack Compute (Nova) services
- openstack.cloud.network_segment - Creates/removes network segments from OpenStack
- openstack.cloud.volume_image_metadata - Manage OpenStack Cinder volume image metadata
- openstack.cloud.volume_retype - Retype (change the volume type of) a Cinder block storage volume
v2.5.0
======
@@ -27,7 +58,6 @@ Minor Changes
- Allow role_assignment module to work cross domain
- Don't compare current state for `reboot_*` actions
- Fix disable_gateway_ip for subnet
- Fix disable_gateway_ip for subnet
- Fix example in the dns_zone_info module doc
- Fix router module external IPs when only subnet specified
- Fix the bug reporting url

View File

@@ -628,7 +628,6 @@ releases:
- Allow role_assignment module to work cross domain
- Don't compare current state for `reboot_*` actions
- Fix disable_gateway_ip for subnet
- Fix disable_gateway_ip for subnet
- Fix example in the dns_zone_info module doc
- Fix router module external IPs when only subnet specified
- Fix the bug reporting url
@@ -636,3 +635,33 @@ releases:
- Shows missing data in `stack_info` module output
release_summary: Bugfixes and minor changes
release_date: '2025-10-24'
2.6.0:
changes:
bugfixes:
- Fix Ansible errors
- Fixed compatability with openstacksdk version 4.15.0
minor_changes:
- Add support for setting the shard key on a baremetal node.
- image - Add support for image ID reservation and queued image creation
- project - Support updating extra_specs
release_summary: New modules for Bare Metal port groups and Neutron network
segments, plus image/project enhancements and bugfixes.
fragments:
- add_shard_key_field_for_baremetal_node.yaml
modules:
- description: Create/Delete Bare Metal port group resources from OpenStack
name: baremetal_port_group
namespace: ''
- description: Update OpenStack Compute (Nova) services
name: compute_service
namespace: ''
- description: Creates/removes network segments from OpenStack
name: network_segment
namespace: ''
- description: Manage OpenStack Cinder volume image metadata
name: volume_image_metadata
namespace: ''
- description: Retype (change the volume type of) a Cinder block storage volume
name: volume_retype
namespace: ''
release_date: '2026-06-10'

View File

View File

@@ -46,6 +46,7 @@ expected_fields:
- reservation
- resource_class
- retired_reason
- shard
- states
- storage_interface
- target_power_state

View File

@@ -46,6 +46,7 @@ expected_fields:
- reservation
- resource_class
- retired_reason
- shard
- states
- storage_interface
- target_power_state

View File

@@ -0,0 +1,12 @@
expected_fields:
- address
- created_at
- extra
- id
- links
- mode
- name
- node_id
- properties
- standalone_ports_supported
- updated_at

View File

@@ -0,0 +1,100 @@
---
# TODO: Actually run this role in CI. Atm we do not have DevStack's ironic plugin enabled.
- name: Create baremetal node
openstack.cloud.baremetal_node:
cloud: "{{ cloud }}"
driver_info:
ipmi_address: "1.2.3.4"
ipmi_username: "admin"
ipmi_password: "secret"
name: ansible_baremetal_node
nics:
- mac: "aa:bb:cc:aa:bb:cc"
state: present
register: node
- name: Create baremetal port group
openstack.cloud.baremetal_port_group:
cloud: "{{ cloud }}"
state: present
name: ansible_baremetal_port_group
node: ansible_baremetal_node
address: fa:16:3e:aa:aa:ab
mode: active-backup
standalone_ports_supported: true
extra:
test: created
properties:
miimon: '100'
register: port_group
- debug: var=port_group
- name: Assert return values of baremetal_port_group module
assert:
that:
# allow new fields to be introduced but prevent fields from being removed
- expected_fields|difference(port_group.port_group.keys())|length == 0
- port_group.port_group.name == "ansible_baremetal_port_group"
- port_group.port_group.node_id == node.node.id
- name: Update baremetal port group
openstack.cloud.baremetal_port_group:
cloud: "{{ cloud }}"
state: present
id: "{{ port_group.port_group.id }}"
mode: 802.3ad
standalone_ports_supported: false
extra:
test: updated
register: updated_port_group
- name: Assert return values of updated baremetal port group
assert:
that:
- updated_port_group is changed
- updated_port_group.port_group.id == port_group.port_group.id
- updated_port_group.port_group.mode == "802.3ad"
- not updated_port_group.port_group.standalone_ports_supported
- updated_port_group.port_group.extra.test == "updated"
- name: Update baremetal port group again
openstack.cloud.baremetal_port_group:
cloud: "{{ cloud }}"
state: present
id: "{{ port_group.port_group.id }}"
mode: 802.3ad
standalone_ports_supported: false
extra:
test: updated
register: updated_port_group
- name: Assert idempotency for baremetal port group module
assert:
that:
- updated_port_group is not changed
- updated_port_group.port_group.id == port_group.port_group.id
- name: Delete baremetal port group
openstack.cloud.baremetal_port_group:
cloud: "{{ cloud }}"
state: absent
id: "{{ port_group.port_group.id }}"
- name: Delete baremetal port group again
openstack.cloud.baremetal_port_group:
cloud: "{{ cloud }}"
state: absent
id: "{{ port_group.port_group.id }}"
register: deleted_port_group
- name: Assert idempotency for deleted baremetal port group
assert:
that:
- deleted_port_group is not changed
- name: Delete baremetal node
openstack.cloud.baremetal_node:
cloud: "{{ cloud }}"
name: ansible_baremetal_node
state: absent

View File

@@ -21,3 +21,50 @@
assert:
that:
- compute_services.compute_services | length > 0
- name: Disable compute service on a node
openstack.cloud.compute_service:
cloud: "{{ cloud }}"
host: "{{ compute_services.compute_services[0].host }}"
binary: 'nova-compute'
status: 'disabled'
disabled_reason: 'maintenance'
register: compute_service_disabled
- name: Assert service being "disabled"
ansible.builtin.assert:
that:
- compute_service_disabled.compute_services | map(attribute='status') | unique | length == 1
- compute_service_disabled.compute_services | map(attribute='disabled_reason') | unique | length == 1
- compute_service_disabled.compute_services[0].status == "disabled"
- compute_service_disabled.compute_services[0].disabled_reason == "maintenance"
- compute_service_disabled is changed
- name: Disable compute service on a node again
openstack.cloud.compute_service:
cloud: "{{ cloud }}"
host: "{{ compute_services.compute_services[0].host }}"
binary: 'nova-compute'
status: 'disabled'
disabled_reason: 'maintenance'
register: compute_service_disabled_again
- name: Assert idempotency of service disable
ansible.builtin.assert:
that:
- compute_service_disabled_again is not changed
- name: Re-enable compute service
openstack.cloud.compute_service:
cloud: "{{ cloud }}"
host: "{{ compute_services.compute_services[0].host }}"
binary: 'nova-compute'
status: 'enabled'
register: compute_service_enabled
- name: Assert service being "disabled"
ansible.builtin.assert:
that:
- compute_service_enabled.compute_services | map(attribute='status') | unique | length == 1
- compute_service_enabled.compute_services[0].status == "enabled"
- compute_service_enabled is changed

View File

@@ -27,6 +27,12 @@
name: ansible_external
external: true
- name: Gather information about external network
openstack.cloud.networks_info:
cloud: "{{ cloud }}"
name: ansible_external
register: external_network
- name: Create external subnet
openstack.cloud.subnet:
cloud: "{{ cloud }}"
@@ -98,6 +104,17 @@
- ip_address: 10.7.7.102
register: port3
- name: Create internal port 4
openstack.cloud.port:
cloud: "{{ cloud }}"
state: present
name: ansible_internal_port4
network: ansible_internal
fixed_ips:
- ip_address: 10.7.7.103
- ip_address: 10.7.7.104
register: port4
- name: Create router 1
openstack.cloud.router:
cloud: "{{ cloud }}"
@@ -136,10 +153,31 @@
selectattr('floating_network_id', '==', public_network.networks.0.id)|
list|length > 0 }}"
# TODO: Replace with appropriate Ansible module once available
- name: Create a floating ip on public network (required for simplest, first floating ip test)
command: openstack --os-cloud={{ cloud }} floating ip create public
when: not public_network_had_fips
block:
- name: Create a floating ip on public network
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: present
network: public
register: public_fip_result
- name: Verify floating ip got created
assert:
that:
- public_fip_result.floating_ip.floating_network_id == public_network.networks.0.id
- name: Create a floating ip on public network again
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: present
network: public
register: public_fip_result
- name: Verify idempotency
assert:
that: public_fip_result is not changed
# TODO: Replace with appropriate Ansible module once available
- name: Create floating ip 1 on external network
@@ -151,6 +189,90 @@
when: fips.floating_ips|length == 0 or
"10.6.6.150" not in fips.floating_ips|map(attribute="floating_ip_address")|list
- name: Create floating ip 2 on external network
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: present
network: ansible_external
floating_ip_address: 10.6.6.151
register: external_fip2_result
- name: Verify floating ip got created
assert:
that:
- external_fip2_result.floating_ip.floating_network_id == external_network.networks.0.id
- name: Update floating ip 2 on external network
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: present
network: ansible_external
floating_ip_address: 10.6.6.151
nat_destination: ansible_internal
fixed_address: 10.7.7.104
register: external_fip2_result
- name: Verify floating ip got updated
assert:
that:
- external_fip2_result is changed
- external_fip2_result.floating_ip.floating_ip_address == "10.6.6.151"
- external_fip2_result.floating_ip.port_id == port4.port.id
- external_fip2_result.floating_ip.fixed_ip_address == "10.7.7.104"
- name: Update floating ip 2 on external network again
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: present
network: ansible_external
floating_ip_address: 10.6.6.151
nat_destination: ansible_internal
fixed_address: 10.7.7.104
register: external_fip2_result
- name: Verify idempotency
assert:
that:
- external_fip2_result is not changed
- name: Detatch floating ip 2 on external network from port
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
network: ansible_external
floating_ip_address: 10.6.6.151
- name: Get floating ip 2 info
openstack.cloud.floating_ip_info:
cloud: "{{ cloud }}"
floating_ip_address: 10.6.6.151
register: external_fip2_result
- name: Verify floating ip got detached
assert:
that:
- external_fip2_result.floating_ips.0.floating_ip_address == "10.6.6.151"
- external_fip2_result.floating_ips.0.fixed_ip_address == none
- external_fip2_result.floating_ips.0.port_id == none
- name: Detatch floating ip 2 on external network from port again
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
network: ansible_external
floating_ip_address: 10.6.6.151
- name: Get floating ip 2 info
openstack.cloud.floating_ip_info:
cloud: "{{ cloud }}"
floating_ip_address: 10.6.6.151
register: external_fip2_result
- name: Verify idempotency
assert:
that:
- external_fip2_result is not changed
- name: Create server 1 with one nic
openstack.cloud.server:
cloud: "{{ cloud }}"
@@ -433,18 +555,41 @@
cloud: "{{ cloud }}"
register: fips
# TODO: Replace with appropriate Ansible module once available
- name: Delete floating ip on public network if we created it
when: not public_network_had_fips
command: >
openstack --os-cloud={{ cloud }} floating ip delete
{{ fips.floating_ips|selectattr('floating_network_id', '==', public_network.networks.0.id)|
map(attribute="floating_ip_address")|list|join(' ') }}
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
purge: true
floating_ip_address: "{{ public_fip }}"
network: public
loop: >-
{{
fips.floating_ips |
selectattr('floating_network_id', '==', public_network.networks.0.id) |
map(attribute="floating_ip_address") |
list
}}
loop_control:
loop_var: public_fip
# TODO: Replace with appropriate Ansible module once available
- name: Delete floating ip 1
command: openstack --os-cloud={{ cloud }} floating ip delete 10.6.6.150
when: fips.floating_ips|length > 0 and "10.6.6.150" in fips.floating_ips|map(attribute="floating_ip_address")|list
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
purge: true
floating_ip_address: 10.6.6.150
network: ansible_external
- name: Delete floating ip 2
when: fips.floating_ips|length > 0 and "10.6.6.151" in fips.floating_ips|map(attribute="floating_ip_address")|list
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
purge: true
floating_ip_address: 10.6.6.151
network: ansible_external
- name: Get remaining floating ips on external network
openstack.cloud.floating_ip_info:
@@ -452,14 +597,24 @@
floating_network: ansible_external
register: fips
# TODO: Replace with appropriate Ansible module once available
# The first, simple floating ip test might have allocated a floating ip on the external network.
# This floating ip must be removed before external network can be deleted.
- name: Delete remaining floating ips on external network
when: fips.floating_ips|length > 0
command: >
openstack --os-cloud={{ cloud }} floating ip delete
{{ fips.floating_ips|map(attribute="floating_ip_address")|list|join(' ') }}
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
purge: true
floating_ip_address: "{{ external_fip }}"
network: ansible_external
loop: >-
{{
fips.floating_ips |
map(attribute="floating_ip_address") |
list
}}
loop_control:
loop_var: external_fip
# Remove routers after floating ips have been detached and disassociated else removal fails with
# Error detaching interface from router ***: Client Error for url: ***,
@@ -478,6 +633,12 @@
state: absent
name: ansible_router1
- name: Delete internal port 4
openstack.cloud.port:
cloud: "{{ cloud }}"
state: absent
name: ansible_internal_port4
- name: Delete internal port 3
openstack.cloud.port:
cloud: "{{ cloud }}"

View File

@@ -69,6 +69,9 @@
external: false
mtu: "{{ mtu }}"
port_security_enabled: "{{ port_security_enabled }}"
tags:
- foo
- bar
register: result_create_nw_with_new_params
ignore_errors: true
@@ -85,6 +88,8 @@
- result_newparams.networks.0.mtu == mtu
- "'is_port_security_enabled' in result_newparams.networks.0"
- result_newparams.networks.0['is_port_security_enabled'] == port_security_enabled
- "'foo' in result_newparams.networks.0.tags"
- "'bar' in result_newparams.networks.0.tags"
- name: Delete network - generic and with new SDK params
openstack.cloud.network:
@@ -115,6 +120,9 @@
external: false
mtu: "{{ mtu }}"
port_security_enabled: "{{ port_security_enabled }}"
tags:
- foo
- bar
register: result_create_nw_for_updates
- name: Update network - update failure
@@ -147,6 +155,11 @@
mtu: "{{ mtu - 50 }}"
# NOTE: This property should be updated
port_security_enabled: "{{ not port_security_enabled }}"
# NOTE: This property should be updated
tags:
- foo
- bar
- baz
register: result_nw_update_success
- name: Gather networks info - updates
@@ -162,6 +175,29 @@
- result_network_updates_info.networks.0.name == network_name_updates
- result_network_updates_info.networks.0.mtu == mtu - 50
- result_network_updates_info.networks.0['is_port_security_enabled'] == (not port_security_enabled)
- "'foo' in result_network_updates_info.networks.0.tags"
- "'bar' in result_network_updates_info.networks.0.tags"
- "'baz' in result_network_updates_info.networks.0.tags"
- name: Update network - no change
openstack.cloud.network:
cloud: "{{ cloud }}"
name: "{{ network_name_updates }}"
state: present
shared: "{{ network_shared }}"
external: false
mtu: "{{ mtu - 50 }}"
port_security_enabled: "{{ not port_security_enabled }}"
tags:
- foo
- bar
- baz
register: result_nw_update_no_change
- name: Verify networks info - no change
assert:
that:
- result_nw_update_no_change is not changed
- name: Delete network - updates
openstack.cloud.network:

View File

@@ -0,0 +1,17 @@
---
expected_fields:
- description
- id
- name
- network_id
- network_type
- physical_network
- segmentation_id
network_name: segment_network
segment_name: example_segment
network_type: vlan
segmentation_id: 999
physical_network: public
initial_description: "example segment description"
updated_description: "updated segment description"

View File

@@ -0,0 +1,72 @@
---
- name: Create network {{ network_name }}
openstack.cloud.network:
cloud: "{{ cloud }}"
name: "{{ network_name }}"
state: present
- name: Create segment {{ segment_name }}
openstack.cloud.network_segment:
cloud: "{{ cloud }}"
name: "{{ segment_name }}"
description: "{{ initial_description }}"
network: "{{ network_name }}"
network_type: "{{ network_type }}"
segmentation_id: "{{ segmentation_id }}"
physical_network: "{{ physical_network }}"
state: present
register: segment
- name: Assert changed
assert:
that: segment is changed
- name: Assert segment fields
assert:
that: item in segment.network_segment
loop: "{{ expected_fields }}"
- name: Update segment {{ segment_name }} by name - no changes
openstack.cloud.network_segment:
cloud: "{{ cloud }}"
name: "{{ segment_name }}"
description: "{{ initial_description }}"
state: present
register: segment
- name: Assert not changed
assert:
that: segment is not changed
- name: Update segment {{ segment_name }} by all fields - changes
openstack.cloud.network_segment:
cloud: "{{ cloud }}"
name: "{{ segment_name }}"
description: "{{ updated_description }}"
network: "{{ network_name }}"
network_type: "{{ network_type }}"
segmentation_id: "{{ segmentation_id }}"
physical_network: "{{ physical_network }}"
state: present
register: segment
- name: Assert changed
assert:
that: segment is changed
- name: Delete segment {{ segment_name }}
openstack.cloud.network_segment:
cloud: "{{ cloud }}"
name: "{{ segment_name }}"
state: absent
register: segment
- name: Assert changed
assert:
that: segment is changed
- name: Delete network {{ network_name }}
openstack.cloud.network:
cloud: "{{ cloud }}"
name: "{{ network_name }}"
state: absent

View File

@@ -0,0 +1,10 @@
expected_fields:
- description
- external_port
- floatingip_id
- id
- internal_ip_address
- internal_port
- internal_port_id
- name
- protocol

View File

@@ -0,0 +1,272 @@
---
- name: Create test network
openstack.cloud.network:
cloud: "{{ cloud }}"
state: present
name: test_internal_network
- name: Create test subnet
openstack.cloud.subnet:
cloud: "{{ cloud }}"
state: present
name: test_internal_subnet
network_name: test_internal_network
cidr: 192.168.100.0/24
gateway_ip: 192.168.100.1
- name: Create test port
openstack.cloud.port:
cloud: "{{ cloud }}"
state: present
name: test_internal_port
network: test_internal_network
fixed_ips:
- ip_address: 192.168.100.10
register: test_internal_port
- name: Create test external network
openstack.cloud.network:
cloud: "{{ cloud }}"
state: present
name: test_external_network
external: true
- name: Create test external subnet
openstack.cloud.subnet:
cloud: "{{ cloud }}"
state: present
network_name: test_external_network
name: test_external_subnet
cidr: 10.6.6.0/24
- name: Create router
openstack.cloud.router:
cloud: "{{ cloud }}"
state: present
name: test_router
network: test_external_network
external_fixed_ips:
- subnet: test_external_subnet
interfaces:
- test_internal_subnet
- name: Create test floating IP
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: present
network: test_external_network
register: test_floating_ip
- name: Test - Create port forwarding rule
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: present
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
network_port: test_internal_port
internal_ip: 192.168.100.10
external_protocol_port: 8080
internal_protocol_port: 80
protocol: tcp
register: pf_create
- name: Get port forwarding info
openstack.cloud.port_forwarding_info:
cloud: "{{ cloud }}"
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
port_forwarding_id: "{{ pf_create.port_forwarding.id }}"
register: pf_create_info
- name: Verify - Port forwarding created successfully
assert:
that:
- pf_create is changed
- pf_create.port_forwarding is defined
- pf_create.port_forwarding.external_port == 8080
- pf_create.port_forwarding.internal_port == 80
- pf_create.port_forwarding.protocol == "tcp"
- pf_create_info.port_forwardings | length == 1
- pf_create_info.port_forwardings.0.id == pf_create.port_forwarding.id
- name: Test - Create port forwarding rule again (idempotency)
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: present
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
network_port: test_internal_port
internal_ip: 192.168.100.10
external_protocol_port: 8080
internal_protocol_port: 80
protocol: tcp
register: pf_idempotent
- name: Verify - No changes
assert:
that:
- pf_idempotent is not changed
- name: Test - Update port forwarding internal port
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: present
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
network_port: test_internal_port
internal_ip: 192.168.100.10
external_protocol_port: 8080
internal_protocol_port: 8080 # Changed from 80 to 8080
protocol: tcp
register: pf_update
- name: Get port forwarding info
openstack.cloud.port_forwarding_info:
cloud: "{{ cloud }}"
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
port_forwarding_id: "{{ pf_update.port_forwarding.id }}"
register: pf_update_info
- name: Verify - Port forwarding updated successfully
assert:
that:
- pf_update is changed
- pf_update.port_forwarding.internal_port == 8080
- pf_update_info.port_forwardings | length == 1
- pf_update_info.port_forwardings.0.id == pf_update.port_forwarding.id
- name: Test - Update with same values (idempotency)
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: present
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
network_port: test_internal_port
internal_ip: 192.168.100.10
external_protocol_port: 8080
internal_protocol_port: 8080
protocol: tcp
register: pf_update_idempotent
- name: Verify - No changes
assert:
that:
- pf_update_idempotent is not changed
- name: Test - Change just one attribute
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: present
port_forwarding_id: "{{ pf_create.port_forwarding.id }}"
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
internal_protocol_port: 9090 # Different internal port
register: pf_update_by_id
- name: Verify - Port forwarding updated by ID
assert:
that:
- pf_update_by_id.changed == true
- pf_update_by_id.port_forwarding.id == pf_create.port_forwarding.id
- pf_update_by_id.port_forwarding.internal_port_id == test_internal_port.port.id
- pf_update_by_id.port_forwarding.internal_ip_address == "192.168.100.10"
- pf_update_by_id.port_forwarding.external_port == 8080
- pf_update_by_id.port_forwarding.internal_port == 9090
- name: Test - Create port forwarding without specifying internal IP
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: present
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
network_port: test_internal_port
external_protocol_port: 2222
internal_protocol_port: 22
protocol: tcp
register: pf_auto_internal_ip
- name: Verify - Port forwarding created with auto internal IP
assert:
that:
- pf_auto_internal_ip.changed == true
- pf_auto_internal_ip.port_forwarding.internal_ip_address == "192.168.100.10"
- name: Test - Delete port forwarding
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: absent
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
network_port: test_internal_port
external_protocol_port: 8080
internal_protocol_port: 9090
protocol: tcp
register: pf_delete
- name: Verify - Port forwarding deleted successfully
assert:
that:
- pf_delete.changed == true
- name: Test - Delete port forwarding by ID
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: absent
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
port_forwarding_id: "{{ pf_auto_internal_ip.port_forwarding.id }}"
register: pf_delete_by_id
- name: Verify - Port forwarding deleted by ID
assert:
that:
- pf_delete_by_id.changed == true
- name: Test - Delete already deleted port forwarding (idempotency)
openstack.cloud.port_forwarding:
cloud: "{{ cloud }}"
state: absent
port_forwarding_id: "{{ pf_auto_internal_ip.port_forwarding.id }}"
floating_ip: "{{ test_floating_ip.floating_ip.id }}"
register: pf_delete_idempotent
- name: Verify - No errors on deleting non-existent rule (idempotency)
assert:
that:
- pf_delete_idempotent is not changed
- pf_delete_idempotent is not failed
- name: Clean up - Delete test floating IP
openstack.cloud.floating_ip:
cloud: "{{ cloud }}"
state: absent
floating_ip_address: "{{ test_floating_ip.floating_ip.floating_ip_address }}"
network: test_external_network
purge: true
- name: Clean up - Delete router
openstack.cloud.router:
cloud: "{{ cloud }}"
state: absent
name: test_router
- name: Clean up - Delete test external subnet
openstack.cloud.subnet:
cloud: "{{ cloud }}"
state: absent
name: test_external_subnet
- name: Clean up - Delete test external network
openstack.cloud.network:
cloud: "{{ cloud }}"
state: absent
name: test_external_network
- name: Clean up - Delete test port
openstack.cloud.port:
cloud: "{{ cloud }}"
state: absent
name: test_internal_port
- name: Clean up - Delete test subnet
openstack.cloud.subnet:
cloud: "{{ cloud }}"
state: absent
name: test_internal_subnet
- name: Clean up - Delete test network
openstack.cloud.network:
cloud: "{{ cloud }}"
state: absent
name: test_internal_network

View File

@@ -174,6 +174,38 @@
that:
- project.project.is_enabled == True
- name: Update project to add new extra_specs
openstack.cloud.project:
cloud: "{{ cloud }}"
state: present
name: ansible_project
extra_specs:
is_enabled: True
another_tag: True
register: project
- name: Assert return values of project module
assert:
that:
- project.project.is_enabled == True
- project.project.another_tag == True
- name: Update project to change existing extra_specs
openstack.cloud.project:
cloud: "{{ cloud }}"
state: present
name: ansible_project
extra_specs:
is_enabled: True
another_tag: False
register: project
- name: Assert return values of project module
assert:
that:
- project.project.is_enabled == True
- project.project.another_tag == False
- name: Delete project
openstack.cloud.project:
cloud: "{{ cloud }}"

View File

@@ -325,6 +325,43 @@
- ports.ports|rejectattr('device_owner', 'equalto', 'network:router_gateway')|sum(attribute='fixed_ips', start=[])|map(attribute='ip_address')|sort|list ==
['10.7.7.1']
- name: Update router (add tags)
openstack.cloud.router:
cloud: "{{ cloud }}"
state: present
name: "{{ router_name }}"
tags:
- foo
- bar
- name: Gather routers info
openstack.cloud.routers_info:
cloud: "{{ cloud }}"
name: "{{ router_name }}"
register: info
- name: Verify tags
assert:
that:
- info.routers.0.name == router_name
- info.routers.0.id == router.router.id
- "'foo' in info.routers.0.tags"
- "'bar' in info.routers.0.tags"
- name: Update router (add tags) again
openstack.cloud.router:
cloud: "{{ cloud }}"
state: present
name: "{{ router_name }}"
tags:
- foo
- bar
register: router
- name: Assert idempotent module
assert:
that: router is not changed
# Admin operation
- name: Create external network
openstack.cloud.network:

View File

@@ -25,3 +25,4 @@ expected_fields:
- updated_at
- use_default_subnet_pool
subnet_name: shade_subnet
segment_name: example_segment

View File

@@ -17,10 +17,20 @@
name: "{{ network_name }}"
state: present
- name: Create network segment {{ segment_name }}
openstack.cloud.network_segment:
cloud: "{{ cloud }}"
name: "{{ segment_name }}"
network: "{{ network_name }}"
network_type: "vxlan"
segmentation_id: 1000
state: present
- name: Create subnet {{ subnet_name }} on network {{ network_name }}
openstack.cloud.subnet:
cloud: "{{ cloud }}"
network_name: "{{ network_name }}"
network_segment: "{{ segment_name }}"
name: "{{ subnet_name }}"
state: present
enable_dhcp: "{{ enable_subnet_dhcp }}"
@@ -31,6 +41,9 @@
gateway_ip: 192.168.0.1
allocation_pool_start: 192.168.0.2
allocation_pool_end: 192.168.0.254
tags:
- foo
- bar
register: subnet
- name: Assert changed
@@ -56,6 +69,9 @@
gateway_ip: 192.168.0.1
allocation_pool_start: 192.168.0.2
allocation_pool_end: 192.168.0.254
tags:
- foo
- bar
register: subnet
- name: Assert not changed
@@ -80,6 +96,8 @@
enable_dhcp: "{{ enable_subnet_dhcp }}"
gateway_ip: 192.168.0.1
cidr: 192.168.0.0/24
tags:
- bar
register: subnet
- name: Verify Subnet info result
@@ -114,6 +132,10 @@
dns_nameservers:
- 8.8.8.7
cidr: 192.168.0.0/24
tags:
- foo
- bar
- baz
register: subnet
- name: Assert changed
@@ -177,6 +199,13 @@
state: absent
register: subnet
- name: Delete network segment {{ segment_name }}
openstack.cloud.network_segment:
cloud: "{{ cloud }}"
name: "{{ segment_name }}"
network: "{{ network_name }}"
state: absent
- name: Delete network {{ network_name }}
openstack.cloud.network:
cloud: "{{ cloud }}"

View File

@@ -53,7 +53,7 @@
- ip_address: 10.5.6.55
register: subport
- name: Create trunk
- name: Create trunk without subports
openstack.cloud.trunk:
cloud: "{{ cloud }}"
state: present
@@ -61,15 +61,17 @@
port: "{{ parent_port_name }}"
register: trunk
- debug: var=trunk
- name: Display return values of trunk module
ansible.builtin.debug:
var: trunk
- name: assert return values of trunk module
assert:
- name: Assert return values of trunk module
ansible.builtin.assert:
that:
# allow new fields to be introduced but prevent fields from being removed
- expected_fields|difference(trunk.trunk.keys())|length == 0
- name: Add subport to trunk
- name: Add subport to trunk by name
openstack.cloud.trunk:
cloud: "{{ cloud }}"
state: present
@@ -79,14 +81,66 @@
- port: "{{ subport_name }}"
segmentation_type: vlan
segmentation_id: 123
register: trunk_subport_by_name
- name: Update subport from trunk
- name: Assert the subport is part of the trunk
ansible.builtin.assert:
that:
- trunk_subport_by_name.trunk.sub_ports|length == 1
- name: Remove subport from trunk
openstack.cloud.trunk:
cloud: "{{ cloud }}"
state: present
name: "{{ trunk_name }}"
port: "{{ parent_port_name }}"
sub_ports: []
register: trunk_subport_removed
- name: Assert no subports are part of the trunk
ansible.builtin.assert:
that:
- trunk_subport_removed.trunk.sub_ports|length == 0
- name: Add subport to trunk by ID
openstack.cloud.trunk:
cloud: "{{ cloud }}"
state: present
name: "{{ trunk_name }}"
port: "{{ parent_port_name }}"
sub_ports:
- port: "{{ subport.port.id }}"
segmentation_type: vlan
segmentation_id: 123
register: trunk_subport_by_id
- name: Assert the subport is part of the trunk
ansible.builtin.assert:
that:
- trunk_subport_by_id.trunk.sub_ports|length == 1
- name: Delete trunk
openstack.cloud.trunk:
cloud: "{{ cloud }}"
state: absent
name: "{{ trunk_name }}"
- name: Create trunk without subports
openstack.cloud.trunk:
cloud: "{{ cloud }}"
state: present
name: "{{ trunk_name }}"
port: "{{ parent_port_name }}"
sub_ports:
- port: "{{ subport.port.id }}"
segmentation_type: vlan
segmentation_id: 123
register: trunk_with_subports
- name: Assert the subport is part of the trunk
ansible.builtin.assert:
that:
- trunk_with_subports.trunk.sub_ports|length == 1
- name: Delete trunk
openstack.cloud.trunk:

View File

@@ -12,14 +12,35 @@
that: item in vol.volume
loop: "{{ expected_fields }}"
- name: Create volume from existing volume
- assert:
that: not vol.volume.is_bootable
- name: Create bootable volume from existing volume
openstack.cloud.volume:
cloud: "{{ cloud }}"
state: present
size: 1
volume: "{{ vol.volume.id }}"
name: ansible_volume1
is_bootable: true
description: Test volume
register: vol
- assert:
that: vol.volume.is_bootable
- name: Make the first volume bootable
openstack.cloud.volume:
cloud: "{{ cloud }}"
state: present
size: 1
name: ansible_volume
is_bootable: true
description: Test volume
register: vol
- assert:
that: vol.volume.is_bootable
- name: Delete volume
openstack.cloud.volume:

View File

@@ -0,0 +1,7 @@
---
volume_image_metadata_cloud: "{{ cloud | default(omit) }}"
volume_image_metadata_volume_name: test-image-metadata-volume
volume_image_metadata_size: 1
volume_image_metadata:
disk_format: qcow2
container_format: bare

View File

@@ -0,0 +1,103 @@
---
- name: Get available images
openstack.cloud.image_info:
cloud: "{{ volume_image_metadata_cloud }}"
register: image_info
- name: Select test image
set_fact:
volume_image_metadata_image_id: >-
{{
image_info.images
| selectattr('status', 'equalto', 'active')
| list
| first
| default({})
}}
- name: Assert an image is available for testing
assert:
that:
- volume_image_metadata_image_id.id is defined
fail_msg: "No active images available in the cloud for volume_image_metadata CI test"
- name: Create a test volume from image
openstack.cloud.volume:
cloud: "{{ volume_image_metadata_cloud }}"
state: present
name: "{{ volume_image_metadata_volume_name }}"
image: "{{ volume_image_metadata_image_id.id }}"
size: "{{ volume_image_metadata_size }}"
register: created_volume
- name: Assert volume was created
assert:
that:
- created_volume.volume is defined
- created_volume.volume.id is defined
- name: Get volume details
openstack.cloud.volume_info:
cloud: "{{ volume_image_metadata_cloud }}"
name: "{{ volume_image_metadata_volume_name }}"
register: volume_info
- name: Assert volume has image metadata
assert:
that:
- volume_info.volumes[0].volume_image_metadata is defined
- volume_info.volumes[0].volume_image_metadata | length > 0
# --------------------------------------------------------------------
# Exercise new module
# --------------------------------------------------------------------
- name: Set volume image metadata
openstack.cloud.volume_image_metadata:
cloud: "{{ volume_image_metadata_cloud }}"
volume: "{{ created_volume.volume.id }}"
image_metadata: "{{ volume_image_metadata }}"
register: image_meta_result
- name: Assert image metadata changed
assert:
that:
- image_meta_result.changed | bool
# --------------------------------------------------------------------
# Idempotency check
# --------------------------------------------------------------------
- name: Set volume image metadata again (idempotent)
openstack.cloud.volume_image_metadata:
cloud: "{{ volume_image_metadata_cloud }}"
volume: "{{ created_volume.volume.id }}"
image_metadata: "{{ volume_image_metadata }}"
register: image_meta_idempotent
- name: Assert idempotent behavior
assert:
that:
- not image_meta_idempotent.changed | bool
# --------------------------------------------------------------------
# Verify metadata persisted
# --------------------------------------------------------------------
- name: Re-fetch volume details
openstack.cloud.volume_info:
cloud: "{{ volume_image_metadata_cloud }}"
name: "{{ volume_image_metadata_volume_name }}"
register: final_volume_info
- name: Verify image metadata values
assert:
that:
- final_volume_info.volumes[0].volume_image_metadata.disk_format == "qcow2"
- final_volume_info.volumes[0].volume_image_metadata.container_format == "bare"
# --------------------------------------------------------------------
# Cleanup
# --------------------------------------------------------------------
- name: Delete test volume
openstack.cloud.volume:
cloud: "{{ volume_image_metadata_cloud }}"
state: absent
name: "{{ volume_image_metadata_volume_name }}"

View File

@@ -40,7 +40,7 @@
- assert:
that:
- new_vol.volume.name == "{{ managed_volume }}"
- new_vol.volume.name == managed_volume
- name: Manage volume again
openstack.cloud.volume_manage:

View File

@@ -0,0 +1,5 @@
---
volume_retype_cloud: "{{ cloud | default(omit) }}"
vtype_src_name: ansible_test_vtype_src
vtype_dst_name: ansible_test_vtype_dst
volume_name: ansible_test_retype_vol

View File

@@ -0,0 +1,183 @@
---
# ci/roles/volume_retype/tasks/main.yml
# Integration tests for openstack.cloud.volume_retype
#
# Prerequisites (set up in surrounding playbook or defaults):
# - cloud: devstack-admin
# - A DevStack instance with at least two volume types
#
# The tests follow the collection's pattern:
# 1. Create prerequisites (volume types, volume)
# 2. Run module - assert changed
# 3. Re-run module - assert NOT changed (idempotency)
# 4. Validate returned data
# 5. Clean up
- name: Create source volume type
openstack.cloud.volume_type:
cloud: "{{ volume_retype_cloud }}"
state: present
name: "{{ vtype_src_name }}"
is_public: true
register: vtype_src
- name: Create destination volume type
openstack.cloud.volume_type:
cloud: "{{ volume_retype_cloud }}"
state: present
name: "{{ vtype_dst_name }}"
is_public: true
register: vtype_dst
- name: Create a test volume using the source type
openstack.cloud.volume:
cloud: "{{ volume_retype_cloud }}"
state: present
name: "{{ volume_name }}"
size: 1
volume_type: "{{ vtype_src_name }}"
wait: true
register: test_volume
- name: Assert volume was created with the source type
ansible.builtin.assert:
that:
- test_volume.volume.volume_type == vtype_src_name
- test_volume.volume.status == 'available'
# ------------------------------------------------------------------
# Test 1: Basic retype (migration_policy=never, same-backend assumed)
# ------------------------------------------------------------------
- name: Retype volume to destination type (migration_policy=never)
openstack.cloud.volume_retype:
cloud: "{{ volume_retype_cloud }}"
volume: "{{ volume_name }}"
new_type: "{{ vtype_dst_name }}"
migration_policy: never
wait: true
timeout: 120
register: retype_result
- name: Assert retype was applied
ansible.builtin.assert:
that:
- retype_result is changed
- retype_result.volume.volume_type == vtype_dst_name
- retype_result.volume.status == 'available'
# ------------------------------------------------------------------
# Test 2: Idempotency re-running with same target type must be no-op
# ------------------------------------------------------------------
- name: Re-run retype with same target type (idempotency check)
openstack.cloud.volume_retype:
cloud: "{{ volume_retype_cloud }}"
volume: "{{ volume_name }}"
new_type: "{{ vtype_dst_name }}"
migration_policy: never
wait: true
register: retype_idempotent
- name: Assert no change on second run
ansible.builtin.assert:
that:
- retype_idempotent is not changed
# ------------------------------------------------------------------
# Test 3: Check mode must report changed without touching the API
# ------------------------------------------------------------------
- name: Check mode retype back to source type (should report changed, no action)
openstack.cloud.volume_retype:
cloud: "{{ volume_retype_cloud }}"
volume: "{{ volume_name }}"
new_type: "{{ vtype_src_name }}"
migration_policy: never
check_mode: true
register: retype_check_mode
- name: Assert check mode reports changed but volume type is still the destination
ansible.builtin.assert:
that:
- retype_check_mode is changed
- name: Confirm actual volume type did NOT change (check mode was dry-run)
openstack.cloud.volume_info:
cloud: "{{ volume_retype_cloud }}"
name: "{{ volume_name }}"
register: volume_info_after_check
- name: Assert volume type unchanged after check mode run
ansible.builtin.assert:
that:
- volume_info_after_check.volumes[0].volume_type == vtype_dst_name
# ------------------------------------------------------------------
# Test 4: Retype by volume ID (not name)
# ------------------------------------------------------------------
- name: Retype volume using its UUID
openstack.cloud.volume_retype:
cloud: "{{ volume_retype_cloud }}"
volume: "{{ test_volume.volume.id }}"
new_type: "{{ vtype_src_name }}"
migration_policy: never
wait: true
register: retype_by_id
- name: Assert retype by ID succeeded
ansible.builtin.assert:
that:
- retype_by_id is changed
- retype_by_id.volume.volume_type == vtype_src_name
# ------------------------------------------------------------------
# Test 5: Error handling non-existent volume
# ------------------------------------------------------------------
- name: Attempt retype of non-existent volume (expect failure)
openstack.cloud.volume_retype:
cloud: "{{ volume_retype_cloud }}"
volume: this-volume-does-not-exist
new_type: "{{ vtype_dst_name }}"
register: retype_missing
ignore_errors: true
- name: Assert that missing volume causes failure
ansible.builtin.assert:
that:
- retype_missing is failed
# ------------------------------------------------------------------
# Test 6: Error handling non-existent volume type
# ------------------------------------------------------------------
- name: Attempt retype to a non-existent volume type (expect failure)
openstack.cloud.volume_retype:
cloud: "{{ volume_retype_cloud }}"
volume: "{{ volume_name }}"
new_type: this-type-does-not-exist
register: retype_missing_type
ignore_errors: true
- name: Assert that missing volume type causes failure
ansible.builtin.assert:
that:
- retype_missing_type is failed
# ------------------------------------------------------------------
# Clean up
# ------------------------------------------------------------------
- name: Delete test volume
openstack.cloud.volume:
cloud: "{{ volume_retype_cloud }}"
state: absent
name: "{{ volume_name }}"
wait: true
- name: Delete source volume type
openstack.cloud.volume_type:
cloud: "{{ volume_retype_cloud }}"
state: absent
name: "{{ vtype_src_name }}"
- name: Delete destination volume type
openstack.cloud.volume_type:
cloud: "{{ volume_retype_cloud }}"
state: absent
name: "{{ vtype_dst_name }}"

View File

@@ -104,9 +104,6 @@ for var in $(env | grep -e '^ANSIBLE_VAR_'); do
ANSIBLE_VARS+="${ANSIBLE_VAR_NAME}=${ANSIBLE_VAR_VALUE} " # concat variables
done
# Discover openstacksdk version
SDK_VER=$(python -c "import openstack; print(openstack.version.__version__)")
# Choose integration tests
tag_opt=""
if [ -n "$TAGS" ]; then
@@ -136,5 +133,5 @@ set -o pipefail
# shellcheck disable=SC2086
ANSIBLE_COLLECTIONS_PATH=$TEST_COLLECTIONS_PATHS ansible-playbook \
-vvv ./run-collection.yml \
-e "sdk_version=${SDK_VER} cloud=${CLOUD} cloud_alt=${CLOUD_ALT} ${ANSIBLE_VARS}" \
-e "cloud=${CLOUD} cloud_alt=${CLOUD_ALT} ${ANSIBLE_VARS}" \
${tag_opt} 2>&1 | sudo tee /opt/stack/logs/test_output.log

View File

@@ -32,11 +32,13 @@
- { role: loadbalancer, tags: loadbalancer }
- { role: logging, tags: logging }
- { role: network, tags: network }
- { role: network_segment, tags: network_segment }
- { role: neutron_rbac_policy, tags: neutron_rbac_policy }
- { role: object, tags: object }
- { role: object_container, tags: object_container }
- { role: object_containers_info, tags: object_containers_info }
- { role: port, tags: port }
- { role: port_forwarding, tags: port_forwarding }
- { role: trait, tags: trait }
- { role: trunk, tags: trunk }
- { role: project, tags: project }
@@ -64,3 +66,5 @@
- { role: volume_service, tags: volume_service }
- { role: volume_snapshot, tags: volume_snapshot }
- { role: volume_type_access, tags: volume_type_access }
- { role: volume_image_metadata, tags: volume_image_metadata }
- { role: volume_retype, tags: volume_retype }

View File

@@ -80,6 +80,8 @@ openstacksdk], please read our [branching docs](branching.md).
+ be based on (be subclasses of) `OpenStackModule` in
`ansible_collections.openstack.cloud.plugins.module_utils.openstack`,
+ should include `extends_documentation_fragment: openstack` in their `DOCUMENTATION` docstring,
+ should contain `version_added` in their `DOCUMENTATION` docstring, where the version is
incremented minor version of the collection,
+ be registered in `meta/action_groups.yml` for enabling the variables to be set in
[group level][ansible-module-defaults].
* Complex functionality, cloud interaction or interoperability code should be moved to [openstacksdk][openstacksdk].
@@ -136,6 +138,22 @@ openstacksdk], please read our [branching docs](branching.md).
results with function parameter `filters`. openstacksdk's proxy layer does not provide an equivalent and thus the
use of `search_users()` is perfectly fine.
## Changelog fragments
Every patch that introduces a new feature, a bugfix, or a breaking change must include a changelog fragment.
When you introduce a new module, the changelog fragment is not required. However, ensure that your module
`DOCUMENTATION` contains `version_added` field in it. Please, increment a minor version of the collection for
the value of `version_added`. For example, if current `openstack.cloud` collection version is `2.5.0`, you
need to use `version_added: 2.6.0`.
As this is an Ansible collection, we follow the Ansible community standard for changelogs. Please note that **the `reno`
tool should not be used** in this repository. Instead, follow the guide linked below to create fragments manually in the
`changelogs/fragments/` directory.
Please refer to the Ansible's [changelog fragments documentation](https://docs.ansible.com/projects/ansible/latest/community/development_process.html#creating-a-changelog-fragment)
for more details on the format, valid sections, and how to create a fragment.
## Testing
* Modules have to be tested with CI integration tests (if possible).

View File

@@ -79,8 +79,7 @@ list(conn.network.ips())[0].to_dict(computed=False)
To run the unit tests of the collection, run this in a Bash shell:
```sh
SDK_VER=$(python -c "import openstack; print(openstack.version.__version__)")
ansible-playbook -vvv ci/run-collection.yml -e "sdk_version=${SDK_VER} cloud=devstack-admin cloud_alt=devstack-alt"
ansible-playbook -vvv ci/run-collection.yml -e "cloud=devstack-admin cloud_alt=devstack-alt"
```
Use `ansible-playbook`'s `--tags` and `--skip-tags` parameters to skip CI tests. For a list of available tags, refer to

View File

@@ -2,16 +2,15 @@
## Publishing to Ansible Galaxy
1. Create entry in [changelog.yaml](../changelogs/changelog.yaml) with commits since last release.
* Modules should be in a separate section `modules`
* Bugfixes and minor changes in their sections
2. Change version in [galaxy.yml](../galaxy.yml). Apply [Semantic Versioning](https://semver.org/):
1. Change version in galaxy.yml. Apply Semantic Versioning:
* Increase major version for breaking changes or modules were removed
* Increase minor version when modules were added
* Increase patch version for bugfixes
3. Run `antsibull-changelog release` command (run `pip install antsibull` before) to generate [CHANGELOG.rst](
../CHANGELOG.rst) and verify correctness of generated files.
4. Commit changes to `changelog.yaml` and `galaxy.yml`, submit patch and wait until it has been merged
2. Run `antsibull-changelog release` command ([antsibull-changelog documentation](
https://docs.ansible.com/projects/ansible/latest/dev_guide/developing_collections_changelogs.html))
to aggregate changelog fragments into changelog.yaml and generate CHANGELOG.rst.
3. Verify correctness of generated files.
4. Commit changes to `changelog.yaml` and `galaxy.yml`, submit patch and wait until it has been merged.
5. Tag the release with version as it's described in [OpenStack docs](
https://docs.opendev.org/opendev/infra-manual/latest/drivers.html#tagging-a-release):
* [Make sure you have a valid GnuPG key pair](

View File

@@ -22,6 +22,8 @@ How to do a review? What to look for when reviewing patches?
attributes to `name` to be consistent with other modules and with openstacksdk. When refactoring a module, then add
the old attribute as an alias to keep backward compatibility.
* Does the module have integration tests in `ci/roles`?
* Does the patch include a changelog fragment? Every new feature, or important bugfix must include one. Ensure that
the fragment follows the Ansible format and that `reno` is not used.
* Is documentation in `DOCUMENTATION`, `RETURN` and `EXAMPLES` up to date?
* Does `RETURN` list all values which are returned by the module?
* Are descriptions, keys, names, types etc. in `RETURN` up to date and sorted?
@@ -46,7 +48,7 @@ How to do a review? What to look for when reviewing patches?
Example:
```sh
ansible-playbook -vvv ci/run-collection.yml \
-e "sdk_version=1.0.0 cloud=devstack-admin cloud_alt=devstack-alt" \
-e "cloud=devstack-admin cloud_alt=devstack-alt" \
--tags floating_ip_info
```
* Does a patch remove any functionality or break backwards compatibility? The author must give a good explanation for

View File

@@ -32,4 +32,4 @@ build_ignore:
- .vscode
- ansible_collections_openstack.egg-info
- changelogs
version: 2.5.0
version: 2.6.0

View File

@@ -1,4 +1,4 @@
requires_ansible: ">=2.8"
requires_ansible: '>=2.8'
action_groups:
openstack:
- address_scope
@@ -10,6 +10,7 @@ action_groups:
- baremetal_node_action
- baremetal_node_info
- baremetal_port
- baremetal_port_group
- baremetal_port_info
- catalog_service
- catalog_service_info
@@ -18,6 +19,7 @@ action_groups:
- compute_flavor
- compute_flavor_access
- compute_flavor_info
- compute_service
- compute_service_info
- config
- dns_zone
@@ -31,6 +33,7 @@ action_groups:
- floating_ip_info
- group_assignment
- host_aggregate
- host_aggregate_info
- identity_domain
- identity_domain_info
- identity_group
@@ -51,6 +54,7 @@ action_groups:
- lb_pool
- loadbalancer
- network
- network_segment
- networks_info
- neutron_rbac_policies_info
- neutron_rbac_policy
@@ -58,6 +62,8 @@ action_groups:
- object_container
- object_containers_info
- port
- port_forwarding
- port_forwarding_info
- port_info
- project
- project_info
@@ -95,3 +101,5 @@ action_groups:
- volume_snapshot
- volume_snapshot_info
- volume_type_access
- volume_image_metadata
- volume_retype

View File

@@ -155,9 +155,6 @@ import sys
from ansible.errors import AnsibleParserError
from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import (
ensure_compatibility
)
try:
import openstack
@@ -179,12 +176,6 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable):
raise AnsibleParserError(
'Could not import Python library openstacksdk')
try:
ensure_compatibility(openstack.version.__version__)
except ImportError as e:
raise AnsibleParserError(
'Incompatible openstacksdk library found: {0}'.format(e))
# Redirect logging to stderr so it does not mix with output, in
# particular JSON output of ansible-inventory.
# TODO: Integrate openstack's logging with Ansible's logging.

View File

@@ -32,61 +32,23 @@
import abc
import copy
from ansible.module_utils.six import raise_from
try:
from ansible.module_utils.compat.version import StrictVersion
except ImportError:
try:
from distutils.version import StrictVersion
except ImportError as exc:
raise_from(ImportError('To use this plugin or module with ansible-core'
' < 2.11, you need to use Python < 3.12 with '
'distutils.version present'), exc)
raise ImportError(f'To use this plugin or module with ansible-core'
f' < 2.11, you need to use Python < 3.12 with '
f'distutils.version present. {exc}')
import importlib
import os
from ansible.module_utils.basic import AnsibleModule
OVERRIDES = {}
CUSTOM_VAR_PARAMS = ['min_ver', 'max_ver']
MINIMUM_SDK_VERSION = '1.0.0'
MAXIMUM_SDK_VERSION = None
def ensure_compatibility(version, min_version=None, max_version=None):
""" Raises ImportError if the specified version does not
meet the minimum and maximum version requirements"""
if min_version and MINIMUM_SDK_VERSION:
min_version = max(StrictVersion(MINIMUM_SDK_VERSION),
StrictVersion(min_version))
elif MINIMUM_SDK_VERSION:
min_version = StrictVersion(MINIMUM_SDK_VERSION)
if max_version and MAXIMUM_SDK_VERSION:
max_version = min(StrictVersion(MAXIMUM_SDK_VERSION),
StrictVersion(max_version))
elif MAXIMUM_SDK_VERSION:
max_version = StrictVersion(MAXIMUM_SDK_VERSION)
if min_version and StrictVersion(version) < min_version:
raise ImportError(
"Version MUST be >={min_version} and <={max_version}, but"
" {version} is smaller than minimum version {min_version}"
.format(version=version,
min_version=min_version,
max_version=max_version))
if max_version and StrictVersion(version) > max_version:
raise ImportError(
"Version MUST be >={min_version} and <={max_version}, but"
" {version} is larger than maximum version {max_version}"
.format(version=version,
min_version=min_version,
max_version=max_version))
def openstack_argument_spec():
# DEPRECATED: This argument spec is only used for the deprecated old
@@ -165,14 +127,6 @@ def openstack_cloud_from_module(module, min_version=None, max_version=None):
except ImportError:
module.fail_json(msg='openstacksdk is required for this module')
try:
ensure_compatibility(sdk.version.__version__,
min_version, max_version)
except ImportError as e:
module.fail_json(
msg="Incompatible openstacksdk library found: {error}."
.format(error=str(e)))
cloud_config = module.params.pop('cloud', None)
try:
if isinstance(cloud_config, dict):
@@ -245,8 +199,6 @@ class OpenStackModule:
deprecated_names = ()
argument_spec = {}
module_kwargs = {}
module_min_sdk_version = None
module_max_sdk_version = None
def __init__(self):
"""Initialize Openstack base class.
@@ -315,19 +267,9 @@ class OpenStackModule:
try:
# Due to the name shadowing we should import other way
sdk = importlib.import_module('openstack')
self.sdk_version = sdk.version.__version__
except ImportError:
self.fail_json(msg='openstacksdk is required for this module')
try:
ensure_compatibility(self.sdk_version,
self.module_min_sdk_version,
self.module_max_sdk_version)
except ImportError as e:
self.fail_json(
msg="Incompatible openstacksdk library found: {error}."
.format(error=str(e)))
# Fail if there are set unsupported for this version parameters
# New parameters should NOT use 'default' but rely on SDK defaults
for param in self.argument_spec:

View File

@@ -243,6 +243,10 @@ node:
retired_reason:
description: TODO
type: str
shard:
description: The shard key for a node.
returned: success
type: str
states:
description: |
Links to the collection of states. Note that this resource is also

View File

@@ -437,6 +437,10 @@ node:
description: The reason the node is marked as retired.
returned: success
type: str
shard:
description: The shard key for a node.
returned: success
type: str
states:
description: Links to the collection of states.
returned: success

View File

@@ -289,6 +289,10 @@ nodes:
description: The reason the node is marked as retired.
returned: success
type: str
shard:
description: The shard key for a node.
returned: success
type: str
states:
description: Links to the collection of states.
returned: success

View File

@@ -0,0 +1,258 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2026 OpenStack Ansible SIG
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r'''
module: baremetal_port_group
short_description: Create/Delete Bare Metal port group resources from OpenStack
author: OpenStack Ansible SIG
description:
- Create, update and remove Bare Metal port groups from OpenStack.
options:
id:
description:
- ID of the port group.
- Will be auto-generated if not specified.
type: str
aliases: ['uuid']
name:
description:
- Name of the port group.
type: str
node:
description:
- ID or Name of the node this resource belongs to.
- Required when creating a new port group.
type: str
address:
description:
- Physical hardware address of this port group, typically the hardware
MAC address.
type: str
extra:
description:
- A set of one or more arbitrary metadata key and value pairs.
type: dict
standalone_ports_supported:
description:
- Whether the port group supports ports that are not members of this
port group.
type: bool
mode:
description:
- The port group mode.
type: str
properties:
description:
- Key/value properties for the port group.
type: dict
state:
description:
- Indicates desired state of the resource.
choices: ['present', 'absent']
default: present
type: str
version_added: 2.6.0
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = r'''
- name: Create Bare Metal port group
openstack.cloud.baremetal_port_group:
cloud: devstack
state: present
name: bond0
node: bm-0
address: fa:16:3e:aa:aa:aa
mode: '802.3ad'
standalone_ports_supported: true
register: result
- name: Update Bare Metal port group
openstack.cloud.baremetal_port_group:
cloud: devstack
state: present
id: 1a85ebca-22bf-42eb-ad9e-f640789b8098
mode: 'active-backup'
properties:
miimon: '100'
register: result
- name: Delete Bare Metal port group
openstack.cloud.baremetal_port_group:
cloud: devstack
state: absent
id: 1a85ebca-22bf-42eb-ad9e-f640789b8098
register: result
'''
RETURN = r'''
port_group:
description: A port group dictionary, subset of the dictionary keys listed
below may be returned, depending on your cloud provider.
returned: success
type: dict
contains:
address:
description: Physical hardware address of the port group.
returned: success
type: str
created_at:
description: Bare Metal port group created at timestamp.
returned: success
type: str
extra:
description: A set of one or more arbitrary metadata key and value
pairs.
returned: success
type: dict
id:
description: The UUID for the Bare Metal port group resource.
returned: success
type: str
links:
description: A list of relative links, including the self and
bookmark links.
returned: success
type: list
mode:
description: The port group mode.
returned: success
type: str
name:
description: Bare Metal port group name.
returned: success
type: str
node_id:
description: UUID of the Bare Metal node this resource belongs to.
returned: success
type: str
properties:
description: Key/value properties for this port group.
returned: success
type: dict
standalone_ports_supported:
description: Whether standalone ports are supported.
returned: success
type: bool
updated_at:
description: Bare Metal port group updated at timestamp.
returned: success
type: str
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import (
OpenStackModule
)
class BaremetalPortGroupModule(OpenStackModule):
argument_spec = dict(
id=dict(aliases=['uuid']),
name=dict(),
node=dict(),
address=dict(),
extra=dict(type='dict'),
standalone_ports_supported=dict(type='bool'),
mode=dict(),
properties=dict(type='dict'),
state=dict(default='present', choices=['present', 'absent']),
)
module_kwargs = dict(
required_one_of=[
('id', 'name'),
],
supports_check_mode=True,
)
def _find_port_group(self):
id_or_name = self.params['id'] if self.params['id'] else self.params['name']
if not id_or_name:
return None
try:
return self.conn.baremetal.find_port_group(id_or_name)
except self.sdk.exceptions.ResourceNotFound:
return None
def _build_create_attrs(self):
attrs = {}
for key in ['id', 'name', 'address', 'extra',
'standalone_ports_supported', 'mode', 'properties']:
if self.params[key] is not None:
attrs[key] = self.params[key]
node_name_or_id = self.params['node']
if not node_name_or_id:
self.fail_json(msg="Parameter 'node' is required when creating a new port group")
node = self.conn.baremetal.find_node(node_name_or_id, ignore_missing=False)
attrs['node_id'] = node['id']
return attrs
def _build_update_attrs(self, port_group):
attrs = {}
for key in ['name', 'address', 'extra',
'standalone_ports_supported', 'mode', 'properties']:
if self.params[key] is not None and self.params[key] != port_group.get(key):
attrs[key] = self.params[key]
return attrs
def _will_change(self, port_group, state):
if state == 'absent':
return bool(port_group)
if not port_group:
return True
return bool(self._build_update_attrs(port_group))
def run(self):
state = self.params['state']
port_group = self._find_port_group()
if self.ansible.check_mode:
if state == 'present' and not port_group:
self._build_create_attrs()
self.exit_json(changed=self._will_change(port_group, state))
if state == 'present':
if not port_group:
port_group = self.conn.baremetal.create_port_group(
**self._build_create_attrs())
self.exit_json(
changed=True,
port_group=port_group.to_dict(computed=False))
update_attrs = self._build_update_attrs(port_group)
changed = bool(update_attrs)
if changed:
port_group = self.conn.baremetal.update_port_group(
port_group['id'], **update_attrs)
self.exit_json(
changed=changed,
port_group=port_group.to_dict(computed=False))
if not port_group:
self.exit_json(changed=False)
self.conn.baremetal.delete_port_group(port_group['id'])
self.exit_json(changed=True)
def main():
module = BaremetalPortGroupModule()
module()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,152 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2025 Boehringer Ingelheim
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r'''
---
module: compute_service
short_description: Update OpenStack Compute (Nova) services
author: OpenStack Ansible SIG
description:
- Update OpenStack Compute (Nova) service properties.
options:
host:
description:
- Compute host name
type: str
required: true
binary:
description:
- Binary name of the service (e.g. C(nova-compute)).
type: str
required: true
status:
description:
- Desired status of the service.
type: str
choices: ['enabled', 'disabled']
default: enabled
disabled_reason:
description:
- Reason for disabling the service. Should be used with state `disabled`.
type: str
version_added: 2.6.0
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = r'''
- name: Disable nova-compute on node1
openstack.cloud.compute_service:
cloud: awesomecloud
host: node1
binary: nova-compute
status: disabled
- name: Enable nova-compute on node1
openstack.cloud.compute_service:
cloud: awesomecloud
host: node1
binary: nova-compute
status: enabled
'''
RETURN = r'''
compute_services:
description: List of dictionaries describing Compute (Nova) services.
returned: always
type: list
elements: dict
contains:
availability_zone:
description: The availability zone name.
type: str
binary:
description: The binary name of the service.
type: str
disabled_reason:
description: The reason why the service is disabled
type: str
id:
description: Unique UUID.
type: str
is_forced_down:
description: If the service has been forced down or nova-compute
type: bool
host:
description: The name of the host.
type: str
name:
description: Service name
type: str
state:
description: The state of the service. One of up or down.
type: str
status:
description: The status of the service. One of enabled or disabled.
type: str
update_at:
description: The date and time when the resource was updated
type: str
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule
class ComputeServiceModule(OpenStackModule):
argument_spec = dict(
host=dict(required=True),
binary=dict(required=True),
status=dict(default="enabled", choices=['enabled', 'disabled']),
disabled_reason=dict(default=None)
)
module_kwargs = dict(
supports_check_mode=True
)
def run(self):
changed = False
kwargs = {k: self.params[k]
for k in ['binary', 'host']
if self.params[k] is not None}
# retrieve current state of service(s)
compute_services = self.conn.compute.services(**kwargs)
# extract desired status
desired_status = self.params['status']
disabled_reason = self.params['disabled_reason']
# apply updates to diverging status
for service in compute_services:
current_status = service.status
if current_status != desired_status:
changed = True
if not self.check_mode:
if desired_status == 'disabled':
self.conn.compute.disable_service(service=service, host=service.host, binary=service.binary, disabled_reason=disabled_reason)
else:
self.conn.compute.enable_service(service=service, host=service.host, binary=service.binary)
# retrieve final state of the service(s)
compute_services = self.conn.compute.services(**kwargs)
self.exit_json(changed=changed,
compute_services=[s.to_dict(computed=False)
for s in compute_services],)
def main():
module = ComputeServiceModule()
module()
if __name__ == '__main__':
main()

View File

@@ -12,6 +12,11 @@ description:
- Create, update or delete an identity provider of the OpenStack
identity (Keystone) service.
options:
authorization_ttl:
description:
- Time to keep the role assignments for users authenticating via this identity provider.
- When not provided, global default configured in the Identity service will be used.
type: int
description:
description:
- The description of the identity provider.
@@ -58,6 +63,7 @@ EXAMPLES = r'''
name: example_provider
domain_id: 0123456789abcdef0123456789abcdef
description: 'My example IDP'
authorization_ttl: 300
remote_ids:
- 'https://auth.example.com/auth/realms/ExampleRealm'
@@ -74,6 +80,10 @@ identity_provider:
returned: On success when I(state) is C(present).
type: dict
contains:
authorization_ttl:
description: Time to keep the role assignments for users authenticating via this identity provider.
type: int
sample: 300
description:
description: Identity provider description
type: str
@@ -104,6 +114,7 @@ from ansible_collections.openstack.cloud.plugins.module_utils.resource import St
class IdentityProviderModule(OpenStackModule):
argument_spec = dict(
authorization_ttl=dict(type='int'),
description=dict(),
domain_id=dict(),
id=dict(required=True, aliases=['name']),
@@ -127,7 +138,7 @@ class IdentityProviderModule(OpenStackModule):
kwargs['attributes'] = \
dict((k, self.params[k])
for k in ['description', 'domain_id', 'id', 'is_enabled',
for k in ['authorization_ttl', 'description', 'domain_id', 'id', 'is_enabled',
'remote_ids']
if self.params[k] is not None)

View File

@@ -40,6 +40,13 @@ options:
required: true
type: list
elements: dict
schema_version:
description:
- The federated attribute mapping schema version.
The default value on the client side is 'None';
however, that will lead the backend to set the default according
to 'attribute_mapping_default_schema_version' option.
type: str
state:
description:
- Whether the mapping should be C(present) or C(absent).
@@ -69,6 +76,7 @@ EXAMPLES = r'''
any_one_of:
- Contractor
- SubContractor
schema_version: '1.0'
- name: Delete a mapping
openstack.cloud.federation_mapping:
@@ -93,6 +101,9 @@ mapping:
rules:
description: List of rules for the mapping
type: list
schema_version:
description: Schema version of the mapping
type: str
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule
@@ -108,6 +119,7 @@ class IdentityFederationMappingModule(OpenStackModule):
local=dict(required=True, type='list', elements='dict'),
remote=dict(required=True, type='list', elements='dict')
)),
schema_version=dict(default=None),
state=dict(default='present', choices=['absent', 'present']),
)
@@ -155,7 +167,7 @@ class IdentityFederationMappingModule(OpenStackModule):
if len(self.params['rules']) < 1:
self.fail_json(msg='At least one rule must be passed')
attributes = dict((k, self.params[k]) for k in ['rules']
attributes = dict((k, self.params[k]) for k in ['rules', 'schema_version']
if k in self.params and self.params[k] is not None
and self.params[k] != mapping[k])
@@ -166,7 +178,8 @@ class IdentityFederationMappingModule(OpenStackModule):
def _create(self):
return self.conn.identity.create_mapping(id=self.params['name'],
rules=self.params['rules'])
rules=self.params['rules'],
schema_version=self.params['schema_version'])
def _delete(self, mapping):
self.conn.identity.delete_mapping(mapping.id)

View File

@@ -17,8 +17,9 @@ description:
options:
fixed_address:
description:
- To which fixed IP of server the floating IP address should be
- To which fixed IP of attached port the floating IP address should be
attached to.
aliases: ["fixed_ip_address"]
type: str
floating_ip_address:
description:
@@ -35,6 +36,7 @@ options:
network:
description:
- The name or ID of a neutron external network or a nova pool name.
- When I(server) is not defined, I(network) is required
type: str
purge:
description:
@@ -57,7 +59,6 @@ options:
description:
- The name or ID of the server to which the IP address
should be assigned.
required: true
type: str
state:
description:
@@ -183,23 +184,24 @@ from ansible_collections.openstack.cloud.plugins.module_utils.openstack import O
class NetworkingFloatingIPModule(OpenStackModule):
argument_spec = dict(
fixed_address=dict(),
fixed_address=dict(aliases=['fixed_ip_address']),
floating_ip_address=dict(),
nat_destination=dict(aliases=['fixed_network', 'internal_network']),
network=dict(),
purge=dict(type='bool', default=False),
reuse=dict(type='bool', default=False),
server=dict(required=True),
server=dict(),
state=dict(default='present', choices=['absent', 'present']),
)
module_kwargs = dict(
required_if=[
['state', 'absent', ['floating_ip_address']]
['state', 'present', ['server', 'network'], True],
['state', 'absent', ['floating_ip_address'], False],
],
required_by={
'floating_ip_address': ('network'),
}
},
)
def run(self):
@@ -214,139 +216,174 @@ class NetworkingFloatingIPModule(OpenStackModule):
changed = False
fixed_address = self.params['fixed_address']
floating_ip_address = self.params['floating_ip_address']
nat_destination_name_or_id = self.params['nat_destination']
nat_destination_id = (
self.nat_destination['id'] if self.nat_destination else None
)
network_id = self.network['id'] if self.network else None
server = self.server
ips = self._find_ips(
server=self.server,
server=server,
floating_ip_address=floating_ip_address,
network_id=network_id,
fixed_address=fixed_address,
nat_destination_name_or_id=nat_destination_name_or_id)
nat_destination_id=nat_destination_id
)
# First floating ip satisfies our requirements
ip = ips[0] if ips else None
ip = None
if floating_ip_address:
# A specific floating ip address has been requested
if not ips:
if server:
if floating_ip_address:
# Requested floating ip address does not exist
self.conn.add_ip_list(
server=server,
ips=[floating_ip_address],
wait=self.params['wait'],
timeout=self.params['timeout'],
fixed_address=fixed_address
)
changed = True
if not ip:
# If a specific floating ip address has been requested
# and it does not exist yet then create it
else:
# No specific floating ip has been requested and none of the
# floating ips which have been assigned to the server matches
# requirements
# openstacksdk's create_ip requires floating_ip_address
# and floating_network_id to be set
self.conn.network.create_ip(
floating_ip_address=floating_ip_address,
floating_network_id=network_id)
# add_ips_to_server() will handle several scenarios:
#
# If a specific floating ip address has been requested then it
# will be attached to the server. The floating ip address has
# either been created in previous steps or it already existed.
# Ref.: https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud
# /_floating_ip.py#L985
#
# If no specific floating ip address has been requested, reuse
# is allowed and a network has been given (with ip_pool) from
# which floating ip addresses will be drawn, then any existing
# floating ip address from ip_pool=network which is not
# attached to any other server will be attached to the server.
# If no such floating ip address exists or if reuse is not
# allowed, then a new floating ip address will be created
# within ip_pool=network and attached to the server.
# Ref.: https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud/
# _floating_ip.py#L981
#
# If no specific floating ip address has been requested and no
# network has been given (with ip_pool) from which floating ip
# addresses will be taken, then a floating ip address might be
# added to the server, refer to _needs_floating_ip() for
# details.
# Ref.:
# * https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud/\
# _floating_ip.py#L989
# * https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud/
# _floating_ip.py#L995
#
# Both floating_ip_address and network are mutually exclusive
# in add_ips_to_server(), i.e.add_ips_to_server will ignore
# floating_ip_address if network is not None. To prefer
# attaching a specific floating ip address over assigning any
# fip, ip_pool is only defined if floating_ip_address is None.
# Ref.: https://github.com/openstack/openstacksdk/blob/
# a6b0ece2821ea79330c4067100295f6bdcbe456e/openstack/cloud/
# _floating_ip.py#L987
self.conn.add_ips_to_server(
server=server,
ip_pool=network_id,
ips=None, # No specific floating ip requested
reuse=self.params['reuse'],
fixed_address=fixed_address,
wait=self.params['wait'],
timeout=self.params['timeout'],
nat_destination=nat_destination_id
)
changed = True
else: # not server
kwargs = self._params_to_kwargs(
floating_ip_address,
network_id,
fixed_address,
self.nat_destination
)
# create the ip
ip = self.conn.network.create_ip(**kwargs)
changed = True
else: # ip
# Requested floating ip address exists already
else: # ips
ip = ips[0]
if ip.port_details and (ip.port_details['status'] == 'ACTIVE') \
and (floating_ip_address not in self._filter_ips(
self.server)):
# Floating ip address exists and has been attached
# but to a different server
if server:
server_ips = self._filter_ips(server)
if ip.floating_ip_address not in server_ips:
port_details = ip.port_details
if (port_details
and port_details['status'] == 'ACTIVE'):
# Requested ip has been attached to different server
self.fail_json(
msg="Floating ip {0} has been attached to "
"different server".format(
floating_ip_address))
# Requested ip has been attached to different server
self.fail_json(
msg="Floating ip {0} has been attached to different "
"server".format(floating_ip_address))
else:
# Requested floating ip address has not been
# assigned to server
self.conn.add_ip_list(
server=server,
ips=[ip.floating_ip_address],
wait=self.params['wait'],
timeout=self.params['timeout'],
fixed_address=fixed_address
)
changed = True
if not ip \
or floating_ip_address not in self._filter_ips(self.server):
# Requested floating ip address does not exist or has not been
# assigned to server
else:
# floating ip is already assigned to the server
pass
elif len(ips) > 1: # not server
self.fail_json(msg='Found more than one floating ip')
self.conn.add_ip_list(
server=self.server,
ips=[floating_ip_address],
wait=self.params['wait'],
timeout=self.params['timeout'],
fixed_address=fixed_address)
changed = True
else:
# Requested floating ip address has been assigned to server
pass
kwargs = self._params_to_kwargs(
floating_ip_address,
network_id,
fixed_address,
self.nat_destination
)
for key, value in kwargs.items():
if ip[key] != value:
self.conn.network.update_ip(ip, **kwargs)
changed = True
break
elif not ips: # and not floating_ip_address
# No specific floating ip has been requested and none of the
# floating ips which have been assigned to the server matches
# requirements
# add_ips_to_server() will handle several scenarios:
#
# If a specific floating ip address has been requested then it
# will be attached to the server. The floating ip address has
# either been created in previous steps or it already existed.
# Ref.: https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud
# /_floating_ip.py#L985
#
# If no specific floating ip address has been requested, reuse
# is allowed and a network has been given (with ip_pool) from
# which floating ip addresses will be drawn, then any existing
# floating ip address from ip_pool=network which is not
# attached to any other server will be attached to the server.
# If no such floating ip address exists or if reuse is not
# allowed, then a new floating ip address will be created
# within ip_pool=network and attached to the server.
# Ref.: https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud/
# _floating_ip.py#L981
#
# If no specific floating ip address has been requested and no
# network has been given (with ip_pool) from which floating ip
# addresses will be taken, then a floating ip address might be
# added to the server, refer to _needs_floating_ip() for
# details.
# Ref.:
# * https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud/\
# _floating_ip.py#L989
# * https://github.com/openstack/openstacksdk/blob/
# 9d3ee1d32149ba2a8bb3dc894295e180746cdddc/openstack/cloud/
# _floating_ip.py#L995
#
# Both floating_ip_address and network are mutually exclusive
# in add_ips_to_server(), i.e.add_ips_to_server will ignore
# floating_ip_address if network is not None. To prefer
# attaching a specific floating ip address over assigning any
# fip, ip_pool is only defined if floating_ip_address is None.
# Ref.: https://github.com/openstack/openstacksdk/blob/
# a6b0ece2821ea79330c4067100295f6bdcbe456e/openstack/cloud/
# _floating_ip.py#L987
self.conn.add_ips_to_server(
server=self.server,
ip_pool=network_id,
ips=None, # No specific floating ip requested
reuse=self.params['reuse'],
fixed_address=fixed_address,
wait=self.params['wait'],
timeout=self.params['timeout'],
nat_destination=nat_destination_name_or_id)
changed = True
else:
# Found one or more floating ips which satisfy requirements
pass
if changed:
if changed and server:
# update server details such as addresses
self.server = self.conn.compute.get_server(self.server)
server = self.conn.compute.get_server(server)
# Update the floating ip resource
ips = self._find_ips(
self.server, floating_ip_address, network_id,
fixed_address, nat_destination_name_or_id)
server,
floating_ip_address,
network_id,
fixed_address,
nat_destination_id
)
# ips can be empty, e.g. when server has no private ipv4
# address to which a floating ip address can be attached
# ips can be empty, e.g. when server has no private ipv4
# address to which a floating ip address can be attached
ip = ips[0] if ips else None
self.exit_json(
changed=changed,
floating_ip=ips[0].to_dict(computed=False) if ips else None)
floating_ip=ip.to_dict(computed=False)
)
def _detach_and_delete(self):
ips = self._find_ips(
@@ -354,7 +391,7 @@ class NetworkingFloatingIPModule(OpenStackModule):
floating_ip_address=self.params['floating_ip_address'],
network_id=self.network['id'] if self.network else None,
fixed_address=self.params['fixed_address'],
nat_destination_name_or_id=self.params['nat_destination'])
nat_destination_id=self.nat_destination['id'] if self.nat_destination else None)
if not ips:
# Nothing to detach
@@ -362,19 +399,22 @@ class NetworkingFloatingIPModule(OpenStackModule):
changed = False
for ip in ips:
if ip['fixed_ip_address']:
# Silently ignore that ip might not be attached to server
#
# self.conn.network.update_ip(ip_id, port_id=None) does not
# handle nova network but self.conn.detach_ip_from_server()
# does so
self.conn.detach_ip_from_server(server_id=self.server['id'],
floating_ip_id=ip['id'])
# OpenStackSDK sets {"port_id": None} to detach a floating
# ip from a device, but there might be a delay until a
# server does not list it in addresses any more.
changed = True
if self.server:
if ip['fixed_ip_address']:
# Silently ignore that ip might not be attached to server
#
# self.conn.network.update_ip(ip_id, port_id=None) does not
# handle nova network but self.conn.detach_ip_from_server()
# does so
changed = self.conn.detach_ip_from_server(server_id=self.server['id'],
floating_ip_id=ip['id'])
# OpenStackSDK sets {"port_id": None} to detach a floating
# ip from a device, but there might be a delay until a
# server does not list it in addresses any more.
else: # not self.server
if ip['port_id']:
changed = True
self.conn.network.update_ip(floating_ip=ip['id'], port_id=None)
if self.params['purge']:
self.conn.network.delete_ip(ip['id'])
@@ -397,39 +437,56 @@ class NetworkingFloatingIPModule(OpenStackModule):
# Returns a list not an iterator here because
# it is iterated several times below
return [address['addr']
for address in _flatten(server['addresses'].values())
if address['OS-EXT-IPS:type'] == 'floating']
addresses = _flatten(server['addresses'].values())
return [
address['addr']
for address in addresses
if address['OS-EXT-IPS:type'] == 'floating'
]
def _find_ips(self,
server,
floating_ip_address,
network_id,
fixed_address,
nat_destination_name_or_id):
nat_destination_id):
# Check which floating ips matches our requirements.
# They might or might not be attached to our server.
if floating_ip_address:
# A specific floating ip address has been requested
ip = self.conn.network.find_ip(floating_ip_address)
return [ip] if ip else []
elif (not fixed_address and nat_destination_name_or_id):
# No specific floating ip and no specific fixed ip have been
# requested but a private network (nat_destination) has been
# given where the floating ip should be attached to.
return self._find_ips_by_nat_destination(
server, nat_destination_name_or_id)
else:
# not floating_ip_address
# and (fixed_address or not nat_destination_name_or_id)
elif server:
if (not fixed_address and nat_destination_id):
# No specific floating ip and no specific fixed ip have been
# requested but a private network (nat_destination) has been
# given where the floating ip should be attached to.
return self._find_ips_by_nat_destination(
server, nat_destination_id)
else:
# not floating_ip_address
# and (fixed_address or not nat_destination_id)
# An analysis of all floating ips of server is required
return self._find_ips_by_network_id_and_fixed_address(
server, fixed_address, network_id)
# An analysis of all floating ips of server is required
return self._find_ips_by_network_id_and_fixed_address(
server, fixed_address, network_id)
elif fixed_address or nat_destination_id:
ports = self._find_ports_by_fixed_address_or_nat_destination(fixed_address, nat_destination_id)
floating_ips = []
for port in ports:
ips = list(self.conn.network.ips(port_id=port.id))
floating_ips.extend(ips)
return floating_ips
elif network_id:
return list(self.conn.network.ips(floating_network_id=network_id))
else:
return []
def _find_ips_by_nat_destination(self,
server,
nat_destination_name_or_id):
nat_destination_id):
if not server['addresses']:
return None
@@ -437,7 +494,7 @@ class NetworkingFloatingIPModule(OpenStackModule):
# Check if we have any floating ip on
# the given nat_destination network
nat_destination = self.conn.network.find_network(
nat_destination_name_or_id, ignore_missing=False)
nat_destination_id, ignore_missing=False)
fips_with_nat_destination = [
addr for addr
@@ -467,7 +524,7 @@ class NetworkingFloatingIPModule(OpenStackModule):
# match network of floating ip
continue
if not fixed_address: # and not nat_destination_name_or_id
if not fixed_address: # and not nat_destination_id
# Any floating ip will fullfil these requirements
matching_ips.append(ip)
@@ -478,20 +535,84 @@ class NetworkingFloatingIPModule(OpenStackModule):
return matching_ips
def _params_to_kwargs(self,
floating_ip_address,
network_id,
fixed_address,
nat_destination):
kwargs = {}
kwargs['floating_network_id'] = network_id
if fixed_address:
# must indicate internal port identifier
ports = self._find_ports_by_fixed_address_or_nat_destination(
fixed_address, nat_destination
)
if len(ports) > 1:
self.fail_json(
msg='There are multiple subnets with the fixed ip '
'address {0}'.format(fixed_address)
)
elif len(ports) == 0:
self.fail_json(
msg='No port found with fixed ip address {0}'.format(
fixed_address)
)
else:
kwargs['fixed_ip_address'] = fixed_address
kwargs['port_id'] = ports[0].id
if floating_ip_address:
kwargs['floating_ip_address'] = floating_ip_address
return kwargs
def _find_ports_by_fixed_address_or_nat_destination(self,
fixed_address,
nat_destination):
port_kwargs = {}
if fixed_address:
port_kwargs['fixed_ips'] = f'ip_address={fixed_address}'
if nat_destination:
port_kwargs['network_id'] = nat_destination.id
ports = self.conn.network.ports(**port_kwargs)
return list(ports)
def _init(self):
server_name_or_id = self.params['server']
server = self.conn.compute.find_server(server_name_or_id,
ignore_missing=False)
# fetch server details such as addresses
self.server = self.conn.compute.get_server(server)
if server_name_or_id:
self.server = self.conn.compute.find_server(
name_or_id=server_name_or_id, ignore_missing=False
)
else:
self.server = None
if (self.server is None and self.params['fixed_address']
and self.params['nat_destination'] is None):
self.fail_json(
msg='fixed_address requires nat_destination to be defined '
'when server isn\'t'
)
network_name_or_id = self.params['network']
if network_name_or_id:
self.network = self.conn.network.find_network(
name_or_id=network_name_or_id, ignore_missing=False)
name_or_id=network_name_or_id, ignore_missing=False
)
else:
self.network = None
nat_destination_name_or_id = self.params['nat_destination']
if nat_destination_name_or_id:
self.nat_destination = self.conn.network.find_network(
name_or_id=nat_destination_name_or_id, ignore_missing=False
)
else:
self.nat_destination = None
def main():
module = NetworkingFloatingIPModule()

View File

@@ -0,0 +1,107 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2025 OpenStack Ansible SIG
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r'''
---
module: host_aggregate_info
short_description: Fetch OpenStack host aggregates
author: OpenStack Ansible SIG
description:
- Fetch OpenStack host aggregates.
options:
name:
description:
- Name or ID of the aggregate.
- Returns only the matching aggregate when set.
type: str
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = r'''
- name: Fetch all host aggregates
openstack.cloud.host_aggregate_info:
cloud: mycloud
- name: Fetch a specific host aggregate by name
openstack.cloud.host_aggregate_info:
cloud: mycloud
name: db_aggregate
'''
RETURN = r'''
aggregates:
description: List of dictionaries describing host aggregates.
returned: always
type: list
elements: dict
contains:
availability_zone:
description: Availability zone of the aggregate.
type: str
created_at:
description: The date and time when the resource was created.
type: str
deleted_at:
description: The date and time when the resource was deleted.
type: str
hosts:
description: List of hosts belonging to the aggregate.
type: list
elements: str
id:
description: The UUID of the aggregate.
type: str
is_deleted:
description: Whether or not the resource is deleted.
type: bool
metadata:
description: Metadata attached to the aggregate.
type: dict
name:
description: Name of the aggregate.
type: str
updated_at:
description: The date and time when the resource was updated.
type: str
uuid:
description: UUID of the aggregate.
type: str
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule
class ComputeHostAggregateInfoModule(OpenStackModule):
argument_spec = dict(
name=dict(),
)
module_kwargs = dict(
supports_check_mode=True
)
def run(self):
name = self.params['name']
if name:
aggregate = self.conn.compute.find_aggregate(name)
aggregates = [aggregate] if aggregate else []
else:
aggregates = list(self.conn.compute.aggregates())
self.exit_json(changed=False,
aggregates=[a.to_dict(computed=False)
for a in aggregates])
def main():
module = ComputeHostAggregateInfoModule()
module()
if __name__ == '__main__':
main()

View File

@@ -485,6 +485,28 @@ class ImageModule(OpenStackModule):
return update_payload
def _wait_for_image_active(self, image):
if not self.params['wait']:
return image
return self.sdk.resource.wait_for_status(
self.conn.image,
image,
status='active',
failures=['error', 'deleted', 'killed'],
wait=self.params['timeout'],
attribute='status')
def _import_uploaded_image(self, image):
if not hasattr(self.conn.image, 'import_image'):
self.fail_json(
msg="The installed openstacksdk library does not support "
"image import operations required for images in the "
"'uploading' state.")
self.conn.image.import_image(image, method='glance-direct')
return self._wait_for_image_active(self.conn.get_image(image.id))
def run(self):
changed = False
image_name_or_id = self.params['id'] or self.params['name']
@@ -529,6 +551,29 @@ class ImageModule(OpenStackModule):
if image['status'] == 'deactivated':
self.conn.image.reactivate_image(image)
changed = True
elif image['status'] == 'queued':
if (
self.params['filename']
and hasattr(self.conn.image, 'stage_image')):
self.conn.image.stage_image(
image, filename=self.params['filename'])
changed = True
elif self.params['filename']:
with open(self.params['filename'], 'rb') as image_data:
self.conn.image.upload_image(
container_format=self.params['container_format'],
disk_format=self.params['disk_format'],
data=image_data,
id=image.id,
name=image.name)
changed = True
image = self.conn.get_image(image.id)
if image['status'] == 'uploading' and self.params['use_import']:
image = self._import_uploaded_image(image)
changed = True
elif image['status'] == 'importing':
image = self._wait_for_image_active(image)
update_payload = self._build_update(image)

View File

@@ -83,6 +83,11 @@ options:
Network will use Openstack defaults if this option is
not provided.
type: str
tags:
description:
- A list of tags to set on the network
type: list
elements: str
extends_documentation_fragment:
- openstack.cloud.openstack
'''
@@ -208,7 +213,8 @@ class NetworkModule(OpenStackModule):
project=dict(),
port_security_enabled=dict(type='bool'),
mtu=dict(type='int', aliases=['mtu_size']),
dns_domain=dict()
dns_domain=dict(),
tags=dict(type='list', elements='str'),
)
def run(self):
@@ -224,6 +230,7 @@ class NetworkModule(OpenStackModule):
provider_network_type = self.params['provider_network_type']
provider_segmentation_id = self.params['provider_segmentation_id']
project = self.params['project']
tags = self.params['tags']
kwargs = {}
for arg in ('port_security_enabled', 'mtu', 'dns_domain'):
@@ -304,6 +311,12 @@ class NetworkModule(OpenStackModule):
)
changed = True
if tags is not None:
old_tags = self.conn.network.get_tags(net)
if set(old_tags) != set(tags):
self.conn.network.set_tags(net, tags)
changed = True
net = net.to_dict(computed=False)
self.exit(changed=changed, network=net, id=net['id'])
elif state == 'absent':

View File

@@ -0,0 +1,184 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2025 British Broadcasting Corporation
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = '''
---
module: network_segment
short_description: Creates/removes network segments from OpenStack
author: OpenStack Ansible SIG
description:
- Add, update or remove network segments from OpenStack.
options:
name:
description:
- Name to be assigned to the segment. Although Neutron allows for
non-unique segment names, this module enforces segment name
uniqueness.
required: true
type: str
description:
description:
- Description of the segment
type: str
network:
description:
- Name or id of the network to which the segment should be attached
type: str
network_type:
description:
- The type of physical network that maps to this segment resource.
type: str
physical_network:
description:
- The physical network where this segment object is implemented.
type: str
segmentation_id:
description:
- An isolated segment on the physical network. The I(network_type)
attribute defines the segmentation model. For example, if the
I(network_type) value is vlan, this ID is a vlan identifier. If
the I(network_type) value is gre, this ID is a gre key.
type: int
state:
description:
- Indicate desired state of the resource.
choices: ['present', 'absent']
default: present
type: str
version_added: 2.6.0
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = '''
# Create a VLAN type network segment named 'segment1'.
- openstack.cloud.network_segment:
cloud: mycloud
name: segment1
network: my_network
network_type: vlan
segmentation_id: 2000
physical_network: my_physnet
state: present
'''
RETURN = '''
id:
description: Id of segment
returned: On success when segment exists.
type: str
network_segment:
description: Dictionary describing the network segment.
returned: On success when network segment exists.
type: dict
contains:
description:
description: Description
type: str
id:
description: Id
type: str
name:
description: Name
type: str
network_id:
description: Network Id
type: str
network_type:
description: Network type
type: str
physical_network:
description: Physical network
type: str
segmentation_id:
description: Segmentation Id
type: int
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import OpenStackModule
class NetworkSegmentModule(OpenStackModule):
argument_spec = dict(
name=dict(required=True),
description=dict(),
network=dict(),
network_type=dict(),
physical_network=dict(),
segmentation_id=dict(type='int'),
state=dict(default='present', choices=['absent', 'present'])
)
def run(self):
state = self.params['state']
name = self.params['name']
network_name_or_id = self.params['network']
kwargs = {}
filters = {}
for arg in ('description', 'network_type', 'physical_network', 'segmentation_id'):
if self.params[arg] is not None:
kwargs[arg] = self.params[arg]
for arg in ('network_type', 'physical_network'):
if self.params[arg] is not None:
filters[arg] = self.params[arg]
if network_name_or_id:
network = self.conn.network.find_network(network_name_or_id,
ignore_missing=False,
**filters)
kwargs['network_id'] = network.id
filters['network_id'] = network.id
segment = self.conn.network.find_segment(name, **filters)
if state == 'present':
if not segment:
segment = self.conn.network.create_segment(name=name, **kwargs)
changed = True
else:
changed = False
update_kwargs = {}
# As the name is required and all other attributes cannot be
# changed (and appear in filters above), we only need to handle
# updates to the description here.
for arg in ["description"]:
if (
arg in kwargs
# ensure user wants something specific
and kwargs[arg] is not None
# and this is not what we have right now
and kwargs[arg] != segment[arg]
):
update_kwargs[arg] = kwargs[arg]
if update_kwargs:
segment = self.conn.network.update_segment(
segment.id, **update_kwargs
)
changed = True
segment = segment.to_dict(computed=False)
self.exit(changed=changed, network_segment=segment, id=segment['id'])
elif state == 'absent':
if not segment:
self.exit(changed=False)
else:
self.conn.network.delete_segment(segment['id'])
self.exit(changed=True)
def main():
module = NetworkSegmentModule()
module()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,249 @@
# Copyright: (c) 2018, Terry Jones <terry.jones@example.org>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r'''
---
module: port_forwarding
short_description: Create/Update/Delete port forwarding resources from OpenStack
description:
- Create, Update and Remove Neutron floating IP port forwarding resources from OpenStack
- Port forwarding allows external traffic to reach instances behind a floating IP
author: OpenStack Ansible SIG
options:
external_protocol_port:
description:
- The external port number on the floating IP that will be forwarded
- Must be between 1 and 65535
- Required if C(port_forwarding_id) is set
type: int
aliases: ['external_port']
floating_ip:
description:
- The floating IP address or ID to create port forwarding on
type: str
required: true
aliases: ['floating_ip_address']
internal_ip:
description:
- The internal IP address to forward traffic to
- Must be one of the fixed IPs on the specified port
- If not specified, uses the first fixed IP of the port
- Requires C(network_port)
type: str
aliases: ['internal_ip_address']
internal_protocol_port:
description:
- The internal port number to forward traffic to
- Must be between 1 and 65535
- Required if C(port_forwarding_id) is set
type: int
aliases: ['internal_port']
network_port:
description:
- The Neutron port name or ID that contains the internal IP
- Required if C(port_forwarding_id) is set
type: str
port_forwarding_id:
description:
- ID of an existing port forwarding resource
- Used for updates and deletions when ID is known
type: str
protocol:
description:
- The IP protocol for the port forwarding resource
- Supports tcp and udp protocols
- Required if C(port_forwarding_id) is set
type: str
state:
description:
- Whether the port forwarding resource should exist or not
type: str
choices: ['present', 'absent']
default: present
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = r'''
- name: Create new port fowarding
openstack.cloud.port_forwarding:
state: present
floating_ip: 192.168.150.67
external_protocol_port: 80
internal_protocol_port: 8080
network_port: example_http_port
protocol: tcp
- name: Update previously created port forwarding
openstack.cloud.port_forwarding:
state: present
port_forwarding_id: existing_port_forwarding
floating_ip: 192.168.150.67
internal_protocol_port: 9090
- name: Delete port forwarding
openstack.cloud.port_forwarding:
state: absent
port_forwarding_id: "resource-id"
floating_ip: "203.0.113.100"
'''
RETURN = r'''
port_forwarding:
description: Dictionary describing the port forwarding resource.
type: list
elements: dict
returned: success
contains:
description:
description: The description of the port forwarding.
type: str
external_port:
description: The external port number.
type: int
floatingip_id:
description: The floating IP id associated with the port forwarding.
type: str
id:
description: The id of the port forwarding.
type: str
internal_ip_address:
description: The internal IP address associated with the port forwarding.
type: str
internal_port:
description: The internal port number.
type: int
internal_port_id:
description: The ID of the network port associated with the port forwarding.
type: str
protocol:
description: The IP protocol used for port forwarding.
type: str
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import (
OpenStackModule
)
class PortForwardingModule(OpenStackModule):
argument_spec = dict(
external_protocol_port=dict(type='int', aliases=['external_port']),
floating_ip=dict(required=True, aliases=['floating_ip_address']),
internal_ip=dict(aliases=['internal_ip_address']),
internal_protocol_port=dict(type='int', aliases=['internal_port']),
network_port=dict(),
port_forwarding_id=dict(),
protocol=dict(),
state=dict(default='present', choices=['present', 'absent']),
)
module_kwargs = dict(
required_if=[
['port_forwarding_id', None, ['external_protocol_port',
'internal_protocol_port',
'network_port',
'protocol'], False],
],
required_by={
'internal_ip': ['network_port'],
},
)
def run(self):
port_forwarding_id = self.params['port_forwarding_id']
floating_ip = self.conn.network.find_ip(self.params['floating_ip'],
ignore_missing=False)
port = self.conn.network.find_port(self.params['network_port']) \
if self.params['network_port'] else None
internal_ip = self._find_internal_ip(port) if port else None
external_port = self.params['external_protocol_port']
internal_port = self.params['internal_protocol_port']
protocol = self.params['protocol']
state = self.params['state']
attrs = {}
if port is not None:
attrs['internal_port_id'] = port.id
if internal_ip is not None:
attrs['internal_ip_address'] = internal_ip
if external_port is not None:
attrs['external_port'] = external_port
if protocol is not None:
attrs['protocol'] = protocol
port_forwarding = self._find_port_forwarding(floating_ip.id,
port_forwarding_id,
attrs)
if internal_port is not None:
attrs['internal_port'] = internal_port
changed = False
if state == 'present':
if port_forwarding:
# found valid pfwd_id or pfwd with matching attributes
new_attrs = {k: v for k, v in attrs.items() if port_forwarding[k] != v}
if new_attrs:
port_forwarding = self.conn.network.update_port_forwarding(
port_forwarding.id, floating_ip.id, **new_attrs)
changed = True
elif not port_forwarding_id:
# pfwd_id not given, so create new pfwd
attrs['floatingip_id'] = floating_ip.id
port_forwarding = self.conn.network.create_port_forwarding(**attrs)
changed = True
self.exit_json(changed=changed, port_forwarding=port_forwarding)
else:
if port_forwarding:
self.conn.network.delete_port_forwarding(port_forwarding.id, floating_ip.id)
changed = True
self.exit_json(changed=changed)
def _find_internal_ip(self, port):
internal_ip = self.params['internal_ip']
if internal_ip:
for fixed_ip in port.fixed_ips:
if fixed_ip['ip_address'] == internal_ip:
return internal_ip
self.fail_json(
msg='Internal IP %s not found in port %s fixed IPs' % (internal_ip, port.id))
else:
if port.fixed_ips:
return port.fixed_ips[0]['ip_address']
else:
self.fail_json(msg='Port %s has no fixed IPs available' % port.id)
def _find_port_forwarding(self, fip_id, pf_id, attrs):
try:
if pf_id:
return self.conn.network.find_port_forwarding(pf_id, fip_id, ignore_missing=False)
port_forwardings = list(self.conn.network.port_forwardings(fip_id, **attrs))
if len(port_forwardings) > 1:
self.fail_json(
msg='Found more than one port forwarding resources with matching attributes')
return port_forwardings[0] if len(port_forwardings) == 1 else None
except self.sdk.exceptions.NotFoundException:
return None
def main():
module = PortForwardingModule()
module()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,148 @@
# Copyright: (c) 2018, Terry Jones <terry.jones@example.org>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r'''
---
module: port_forwarding_info
short_description: Retrieve port forwarding resources from OpenStack.
description:
- Retrieve Neutron floating IP port forwarding resources from OpenStack.
author: OpenStack Ansible SIG
options:
external_port:
description:
- The external port number on the floating IP that will be forwarded.
type: int
floating_ip:
description:
- The address or ID of a floating IP that contains a port forwarding.
type: str
internal_port_id:
description:
- The Neutron port ID.
type: str
port_forwarding_id:
description:
- ID of an existing port forwarding resource.
type: str
protocol:
description:
- The IP protocol for the port forwarding resource.
type: str
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = r'''
# Getting all port forwardings
- openstack.cloud.port_forwarding_info:
register: pfwds
# Getting port forwardings by associated floating ip
- openstack.cloud.port_forwarding_info:
floating_ip: 192.168.42.67
register: pfwds
# Getting port forwarding by port forwarding id
- openstack.cloud.port_forwarding_info:
port_forwarding_id: d09f88d6-bb20-4268-9139-27c1b82c51d0
register: pfwd
'''
RETURN = r'''
port_forwardings:
description: The port forwarding objects list.
type: list
elements: dict
returned: success
contains:
description:
description: The description of the port forwarding.
type: str
external_port:
description: The external port number.
type: int
floatingip_id:
description: The floating IP id associated with the port forwarding.
type: str
id:
description: The id of the port forwarding.
type: str
internal_ip_address:
description: The internal IP address associated with the port forwarding.
type: str
internal_port:
description: The internal port number.
type: int
internal_port_id:
description: The ID of the network port associated with the port forwarding.
type: str
protocol:
description: The IP protocol used for port forwarding.
type: str
'''
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import (
OpenStackModule
)
class PortForwardingInfoModule(OpenStackModule):
argument_spec = dict(
external_port=dict(type='int'),
floating_ip=dict(),
internal_port_id=dict(),
port_forwarding_id=dict(),
protocol=dict(),
)
module_kwargs = dict(
supports_check_mode=True
)
def _find_port_forwardings(self):
port_forwarding_id = self.params['port_forwarding_id']
floating_ip = self.params['floating_ip']
query_kwargs = {k: self.params[k]
for k in ['external_port',
'internal_port_id',
'protocol']
if self.params[k] is not None}
floating_ips = None
if floating_ip:
fip = self.conn.network.find_ip(floating_ip)
floating_ips = [fip] if fip else []
else:
floating_ips = self.conn.network.ips()
port_forwardings = []
if port_forwarding_id is None:
for fip in floating_ips:
pfwds = self.conn.network.port_forwardings(fip.id, **query_kwargs)
port_forwardings.extend(list(pfwds))
else:
for fip in floating_ips:
pfwd = self.conn.network.find_port_forwarding(
port_forwarding_id, fip.id, query_kwargs)
if pfwd:
return [pfwd]
return port_forwardings
def run(self):
port_forwardings = [pfwd.to_dict(computed=False)
for pfwd in self._find_port_forwardings()]
self.exit(changed=False, port_forwardings=port_forwardings)
def main():
module = PortForwardingInfoModule()
module()
if __name__ == '__main__':
main()

View File

@@ -181,7 +181,7 @@ class IdentityProjectModule(OpenStackModule):
raise ValueError('Duplicate key(s) in extra_specs: {0}'
.format(', '.join(list(duplicate_keys))))
for k, v in extra_specs.items():
if v != project[k]:
if k not in project or v != project[k]:
attributes[k] = v
if attributes:

View File

@@ -67,6 +67,9 @@ options:
l7_policies:
description: The maximum amount of L7 policies you can create.
type: int
l7_rules:
description: The maximum amount of L7 rules you can create.
type: int
listeners:
description: The maximum number of listeners you can create.
type: int
@@ -251,6 +254,10 @@ quotas:
description: The maximum amount of L7 policies you can
create.
type: int
l7_rules:
description: The maximum amount of L7 rules you can
create.
type: int
listeners:
description: The maximum number of listeners you can create
type: int
@@ -364,6 +371,7 @@ quotas:
health_monitors: 10,
load_balancers: 10,
l7_policies: 10,
l7_rules: 10,
listeners: 10,
pools: 5,
members: 5,
@@ -395,6 +403,7 @@ class QuotaModule(OpenStackModule):
instances=dict(type='int'),
key_pairs=dict(type='int', no_log=False),
l7_policies=dict(type='int'),
l7_rules=dict(type='int'),
listeners=dict(type='int'),
load_balancers=dict(type='int', aliases=['loadbalancer']),
metadata_items=dict(type='int'),

View File

@@ -112,6 +112,11 @@ options:
choices: ['present', 'absent']
default: present
type: str
tags:
description:
- A list of tags to set on the network
type: list
elements: str
extends_documentation_fragment:
- openstack.cloud.openstack
'''
@@ -326,6 +331,7 @@ class RouterModule(OpenStackModule):
network=dict(),
project=dict(),
state=dict(default='present', choices=['absent', 'present']),
tags=dict(type='list', elements='str'),
)
module_kwargs = dict(
@@ -603,6 +609,7 @@ class RouterModule(OpenStackModule):
name = self.params['name']
network_name_or_id = self._get_external_gateway_network_name()
project_name_or_id = self.params['project']
tags = self.params['tags']
if self.params['external_fixed_ips'] and not network_name_or_id:
self.fail(
@@ -686,6 +693,12 @@ class RouterModule(OpenStackModule):
self._update_ifaces(router, to_add, to_remove,
missing_internal_ports)
if tags is not None:
old_tags = self.conn.network.get_tags(router)
if set(old_tags) != set(tags):
self.conn.network.set_tags(router, tags)
changed = True
self.exit_json(changed=changed,
router=router.to_dict(computed=False))

View File

@@ -195,10 +195,12 @@ options:
added.
- On server creation, if I(security_groups) is omitted, the API creates
the server in the default security group.
- Requested security groups are not applied to pre-existing ports.
- On server creation, requested security groups are not applied to
pre-existing ports.
- On update, if I(security_groups) is set, the security groups are
applied to all attached ports.
type: list
elements: str
default: []
state:
description:
- Should the resource be C(present) or C(absent).
@@ -830,7 +832,7 @@ class ServerModule(OpenStackModule):
nics=dict(default=[], type='list', elements='raw'),
reuse_ips=dict(default=True, type='bool'),
scheduler_hints=dict(type='dict'),
security_groups=dict(default=[], type='list', elements='str'),
security_groups=dict(type='list', elements='str'),
state=dict(default='present', choices=['absent', 'present']),
tags=dict(type='list', default=[], elements='str'),
terminate_volume=dict(default=False, type='bool'),
@@ -952,6 +954,9 @@ class ServerModule(OpenStackModule):
def _build_update_security_groups(self, server):
update = {}
if self.params['security_groups'] is None:
return update
required_security_groups = dict(
(sg['id'], sg) for sg in [
self.conn.network.find_security_group(

View File

@@ -115,6 +115,10 @@ options:
- Required when I(state) is 'present'
aliases: ['network_name']
type: str
network_segment:
description:
- Name or id of the network segment to which the subnet should be associated
type: str
project:
description:
- Project name or ID containing the subnet (name admin-only)
@@ -133,6 +137,11 @@ options:
- The subnet pool name or ID from which to obtain a CIDR
type: str
required: false
tags:
description:
- A list of tags to set on the network
type: list
elements: str
extends_documentation_fragment:
- openstack.cloud.openstack
'''
@@ -294,6 +303,7 @@ class SubnetModule(OpenStackModule):
argument_spec = dict(
name=dict(required=True),
network=dict(aliases=['network_name']),
network_segment=dict(),
cidr=dict(),
description=dict(),
ip_version=dict(type='int', default=4, choices=[4, 6]),
@@ -317,6 +327,7 @@ class SubnetModule(OpenStackModule):
state=dict(default='present',
choices=['absent', 'present']),
project=dict(),
tags=dict(type='list', elements='str'),
)
module_kwargs = dict(
@@ -369,9 +380,11 @@ class SubnetModule(OpenStackModule):
return [dict(start=pool_start, end=pool_end)]
return None
def _build_params(self, network, project, subnet_pool):
def _build_params(self, network, segment, project, subnet_pool):
params = {attr: self.params[attr] for attr in self.attr_params}
params['network_id'] = network.id
if segment:
params['segment_id'] = segment.id
if project:
params['project_id'] = project.id
if subnet_pool:
@@ -416,11 +429,13 @@ class SubnetModule(OpenStackModule):
def run(self):
state = self.params['state']
network_name_or_id = self.params['network']
network_segment_name_or_id = self.params['network_segment']
project_name_or_id = self.params['project']
subnet_pool_name_or_id = self.params['subnet_pool']
subnet_name = self.params['name']
gateway_ip = self.params['gateway_ip']
disable_gateway_ip = self.params['disable_gateway_ip']
tags = self.params['tags']
# fail early if incompatible options have been specified
if disable_gateway_ip and gateway_ip:
@@ -444,6 +459,13 @@ class SubnetModule(OpenStackModule):
**filters)
filters['network_id'] = network.id
segment = None
if network_segment_name_or_id:
segment = self.conn.network.find_segment(network_segment_name_or_id,
ignore_missing=False,
**filters)
filters['segment_id'] = segment.id
subnet_pool = None
if subnet_pool_name_or_id:
subnet_pool = self.conn.network.find_subnet_pool(
@@ -460,7 +482,7 @@ class SubnetModule(OpenStackModule):
changed = False
if state == 'present':
params = self._build_params(network, project, subnet_pool)
params = self._build_params(network, segment, project, subnet_pool)
if subnet is None:
subnet = self.conn.network.create_subnet(**params)
changed = True
@@ -470,6 +492,12 @@ class SubnetModule(OpenStackModule):
self._validate_update(subnet, updates)
subnet = self.conn.network.update_subnet(subnet, **updates)
changed = True
if tags is not None:
old_tags = self.conn.network.get_tags(subnet)
if set(old_tags) != set(tags):
self.conn.network.set_tags(subnet, tags)
changed = True
self.exit_json(changed=changed, subnet=subnet, id=subnet.id)
elif state == 'absent' and subnet is not None:
self.conn.network.delete_subnet(subnet)

View File

@@ -201,6 +201,11 @@ class TrunkModule(OpenStackModule):
if state == 'present' and not trunk:
# create trunk
trunk = self._create(name_or_id, port)
# add sub ports
update = self._build_update(trunk, sub_ports)
trunk = self._update(trunk, update)
self.exit_json(changed=True,
trunk=trunk.to_dict(computed=False))
elif state == 'present' and trunk:
@@ -232,7 +237,7 @@ class TrunkModule(OpenStackModule):
if found is False:
psp = self.params['sub_ports'] or []
for k in psp:
if sp['name'] == k['port']:
if sp['name'] == k['port'] or sp['id'] == k['port']:
spobj = {
'port_id': sp['id'],
'segmentation_type': k['segmentation_type'],

View File

@@ -15,16 +15,19 @@ options:
availability_zone:
description:
- The availability zone.
- This attribute cannot be updated.
type: str
description:
description:
- String describing the volume
- This attribute cannot be updated.
type: str
aliases: [display_description]
image:
description:
- Image name or id for boot from volume
- Mutually exclusive with I(snapshot) and I(volume)
- This attribute cannot be updated.
type: str
is_bootable:
description:
@@ -40,30 +43,36 @@ options:
- Note that support for multiattach volumes depends on the volume
type being used.
- "Cinder's default for I(is_multiattach) is C(false)."
- This attribute cannot be updated.
type: bool
metadata:
description:
- Metadata for the volume
- This attribute cannot be updated.
type: dict
name:
description:
- Name of volume
- This attribute cannot be updated.
required: true
type: str
aliases: [display_name]
scheduler_hints:
description:
- Scheduler hints passed to volume API in form of dict
- This attribute cannot be updated.
type: dict
size:
description:
- Size of volume in GB. This parameter is required when the
I(state) parameter is 'present'.
- This attribute can only be updated to a larger size.
type: int
snapshot:
description:
- Volume snapshot name or id to create from
- Mutually exclusive with I(image) and I(volume)
- This attribute cannot be updated.
type: str
aliases: [snapshot_id]
state:
@@ -76,10 +85,12 @@ options:
description:
- Volume name or id to create from
- Mutually exclusive with I(image) and I(snapshot)
- This attribute cannot be updated.
type: str
volume_type:
description:
- Volume type for volume
- This attribute cannot be updated.
type: str
extends_documentation_fragment:
- openstack.cloud.openstack
@@ -238,16 +249,13 @@ class VolumeModule(OpenStackModule):
)
def _build_update(self, volume):
keys = ('size',)
keys = ('size', 'is_bootable')
return {k: self.params[k] for k in keys if self.params[k] is not None
and self.params[k] != volume[k]}
def _update(self, volume):
'''
modify volume, the only modification to an existing volume
available at the moment is extending the size, this is
limited by the openstacksdk and may change whenever the
functionality is extended.
modify volume. If the size has changed, it can only be extended.
'''
diff = {'before': volume.to_dict(computed=False), 'after': ''}
diff['after'] = diff['before']
@@ -259,15 +267,19 @@ class VolumeModule(OpenStackModule):
volume=volume.to_dict(computed=False), diff=diff)
if self.ansible.check_mode:
volume.size = update['size']
for k, v in update:
volume[k] = v
self.exit_json(changed=False,
volume=volume.to_dict(computed=False), diff=diff)
if 'size' in update and update['size'] != volume.size:
size = update['size']
self.conn.volume.extend_volume(volume.id, size)
volume = self.conn.block_storage.get_volume(volume)
if 'is_bootable' in update and update['is_bootable'] != volume.is_bootable:
self.conn.volume.set_volume_bootable_status(volume, update['is_bootable'])
volume = self.conn.block_storage.get_volume(volume)
volume = volume.to_dict(computed=False)
diff['after'] = volume
self.exit_json(changed=True, volume=volume, diff=diff)
@@ -310,6 +322,10 @@ class VolumeModule(OpenStackModule):
self.conn.block_storage.wait_for_status(
volume, wait=self.params['timeout'])
if self.params['is_bootable']:
self.conn.volume.set_volume_bootable_status(volume, True)
volume.is_bootable = True
volume = volume.to_dict(computed=False)
diff['after'] = volume
self.exit_json(changed=True, volume=volume, diff=diff)

View File

@@ -0,0 +1,98 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2025 by Pure Storage, Inc.
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r"""
---
module: volume_image_metadata
short_description: Manage OpenStack Cinder volume image metadata
extends_documentation_fragment:
- openstack.cloud.openstack
description:
- Set image metadata on a Cinder volume.
- This maps to the Cinder C(os-set_image_metadata) API action.
- This is distinct from regular volume metadata.
options:
volume:
description:
- Volume ID or name.
required: true
type: str
image_metadata:
description:
- Image metadata to apply to the volume.
required: true
type: dict
author:
- Simon Dodsley (@simondodsley)
version_added: 2.6.0
"""
EXAMPLES = r"""
- name: Apply volume image metadata
openstack.cloud.volume_image_metadata:
cloud: mycloud
volume: 9c6b7c8d-1234
image_metadata:
image_id: 2e1a...
disk_format: qcow2
container_format: bare
"""
RETURN = r"""
changed:
description: Whether the volume image metadata was changed.
returned: always
type: bool
volume:
description: Volume information.
returned: always
type: dict
"""
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import (
OpenStackModule,
)
class VolumeImageMetadataModule(OpenStackModule):
argument_spec = dict(
volume=dict(required=True),
image_metadata=dict(type="dict", required=True),
)
module_kwargs = dict(
supports_check_mode=True,
)
def run(self):
volume_ref = self.params["volume"]
desired_meta = self.params["image_metadata"]
# Resolve volume
volume = self.conn.block_storage.find_volume(volume_ref, ignore_missing=False)
current_meta = volume.volume_image_metadata or {}
# Idempotency check
if desired_meta.items() <= current_meta.items():
self.exit_json(changed=False, volume=volume.to_dict())
if not self.ansible.check_mode:
self.conn.block_storage.set_volume_image_metadata(volume.id, **desired_meta)
volume = self.conn.block_storage.get_volume(volume.id)
self.exit_json(changed=True, volume=volume.to_dict())
def main():
module = VolumeImageMetadataModule()
module()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,322 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2024 OpenStack Ansible Contributors
# GNU General Public License v3.0+
# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
DOCUMENTATION = r'''
---
module: volume_retype
short_description: Retype (change the volume type of) a Cinder block storage volume
author: Simon Dodsley (@simondodsley)
description:
- Change the volume type of an existing OpenStack Block Storage (Cinder) volume.
- This operation may trigger a data migration between storage backends, depending
on the chosen C(migration_policy).
- The volume must be in C(available) or C(in-use) status to be retyped.
- Retyping an in-use volume from a multiattach-capable type to a
non-multiattach-capable type (or vice-versa) is not supported by Cinder.
- Retyping an encrypted volume to an unencrypted volume (or vice-versa) is not
supported by Cinder.
options:
volume:
description:
- Name or ID of the volume to retype.
type: str
required: true
aliases: ['name']
new_type:
description:
- Name or ID of the target volume type.
type: str
required: true
migration_policy:
description:
- Migration policy to apply when changing the volume type.
- C(never) changes the volume type metadata only, without migrating data.
This only works when the source and target types share the same storage
backend. This is the Cinder default.
- C(on-demand) triggers a full data migration to the backend associated
with the new volume type. This allows moving between different backends
but may take a significant amount of time depending on volume size.
type: str
choices: ['never', 'on-demand']
default: 'never'
wait:
description:
- Whether to wait for the retype operation to complete before returning.
- When I(wait=true) and I(migration_policy=on-demand) the module will
poll until the volume C(migration_status) is C(success) or C(error).
- When I(wait=true) and I(migration_policy=never) the module waits until
the volume returns to C(available) status.
type: bool
default: true
timeout:
description:
- How long (in seconds) to wait for the retype operation to complete.
- Ignored when I(wait=false).
type: int
default: 600
version_added: 2.6.0
extends_documentation_fragment:
- openstack.cloud.openstack
'''
EXAMPLES = r'''
- name: Retype a volume using the default "never" migration policy
openstack.cloud.volume_retype:
cloud: mycloud
volume: my-volume
new_type: ssd-standard
- name: Retype a volume with on-demand migration (moves data to the new backend)
openstack.cloud.volume_retype:
cloud: mycloud
volume: my-volume
new_type: high-iops
migration_policy: on-demand
timeout: 1800
- name: Retype a volume without waiting for completion
openstack.cloud.volume_retype:
cloud: mycloud
volume: my-volume
new_type: cold-storage
migration_policy: on-demand
wait: false
- name: Retype volume identified by UUID
openstack.cloud.volume_retype:
cloud: mycloud
volume: a1b2c3d4-1234-5678-abcd-ef0123456789
new_type: replicated-ssd
migration_policy: on-demand
'''
RETURN = r'''
volume:
description: Dictionary describing the volume after the retype operation.
returned: On success.
type: dict
contains:
attachments:
description: Instance attachments for the volume.
type: list
elements: dict
availability_zone:
description: The availability zone of the volume.
type: str
bootable:
description: Whether the volume is bootable.
type: bool
consistencygroup_id:
description: The ID of the consistency group that the volume belongs to.
type: str
created_at:
description: Timestamp of when the volume was created.
type: str
description:
description: Human-readable description of the volume.
type: str
encrypted:
description: Whether the volume is encrypted.
type: bool
id:
description: Unique ID of the volume.
type: str
metadata:
description: Metadata associated with the volume.
type: dict
migration_status:
description:
- Status of the most recent migration operation.
- Will be C(success) after a successful on-demand retype.
type: str
name:
description: Name of the volume.
type: str
replication_status:
description: Replication status of the volume.
type: str
size:
description: Volume size in GiB.
type: int
snapshot_id:
description: Snapshot ID from which the volume was created.
type: str
source_volid:
description: ID of the source volume, if the volume was cloned from another.
type: str
status:
description: Volume status. Will be C(available) after a successful retype.
type: str
updated_at:
description: Timestamp of the most recent volume update.
type: str
volume_type:
description: The volume type of the volume after the retype.
type: str
volume_type_id:
description: The ID of the volume type after the retype.
type: str
'''
import time
from ansible_collections.openstack.cloud.plugins.module_utils.openstack import (
OpenStackModule,
)
class VolumeRetypeModule(OpenStackModule):
argument_spec = dict(
volume=dict(required=True, aliases=['name']),
new_type=dict(required=True),
migration_policy=dict(
default='never',
choices=['never', 'on-demand'],
),
wait=dict(type='bool', default=True),
timeout=dict(type='int', default=600),
)
module_kwargs = dict(
supports_check_mode=True,
)
def _get_volume(self, name_or_id):
"""Retrieve the Cinder volume resource, failing if not found."""
volume = self.conn.block_storage.find_volume(
name_or_id, ignore_missing=False
)
return volume
def _get_volume_type(self, name_or_id):
"""Retrieve the Cinder volume type resource, failing if not found.
Uses the v3 proxy's find_type(name_or_id, ignore_missing=False) which
accepts both names and UUIDs and raises NotFoundException on miss.
"""
return self.conn.block_storage.find_type(
name_or_id, ignore_missing=False
)
def _volume_needs_retype(self, volume, target_type):
"""Return True when the volume's current type differs from the target."""
# openstacksdk Volume resources expose volume_type (name) and
# volume_type_id; compare by ID when available, fall back to name.
if volume.volume_type_id and target_type.id:
return volume.volume_type_id != target_type.id
return (volume.volume_type or '').lower() != (target_type.name or '').lower()
def _wait_for_retype(self, volume_id, migration_policy, timeout):
"""
Block until the volume completes the retype, or raise on timeout/error.
For on-demand policy: poll migration_status until 'success' or 'error'.
For never policy: poll status until 'available' or an error state.
"""
error_statuses = {'error', 'error_deleting', 'error_restoring',
'error_extending', 'error_managing'}
deadline = time.time() + timeout
while time.time() < deadline:
volume = self.conn.block_storage.get_volume(volume_id)
if migration_policy == 'on-demand':
migration_status = getattr(volume, 'migration_status', None) or ''
if migration_status == 'success':
return volume
if migration_status == 'error':
self.fail_json(
msg="Volume retype migration failed: migration_status=error",
volume=volume.to_dict(),
)
else: # migration_policy == 'never'
status = (volume.status or '').lower()
if status == 'available':
return volume
if status in error_statuses:
self.fail_json(
msg="Volume entered error state during retype: "
"status={0}".format(volume.status),
volume=volume.to_dict(),
)
time.sleep(5)
self.fail_json(
msg="Timed out waiting for volume retype to complete after "
"{0} seconds.".format(timeout)
)
def run(self):
volume_param = self.params['volume']
new_type_param = self.params['new_type']
migration_policy = self.params['migration_policy']
wait = self.params['wait']
timeout = self.params['timeout']
# ---- Resolve the volume ----
volume = self._get_volume(volume_param)
# ---- Resolve the target volume type ----
target_type = self._get_volume_type(new_type_param)
# ---- Idempotency check ----
if not self._volume_needs_retype(volume, target_type):
self.exit_json(
changed=False,
volume=volume.to_dict(),
)
# ---- Validate volume status ----
allowed_statuses = {'available', 'in-use'}
if (volume.status or '').lower() not in allowed_statuses:
self.fail_json(
msg="Cannot retype volume '{0}': status must be one of {1}, "
"but is '{2}'.".format(
volume.id,
allowed_statuses,
volume.status,
)
)
# ---- Check mode: report what would change without making API calls ----
if self.ansible.check_mode:
self.exit_json(
changed=True,
volume=volume.to_dict(),
)
# ---- Perform the retype ----
# openstacksdk exposes retype via the block_storage proxy (SDK >= 1.x).
# The Cinder REST action is POST /volumes/{id}/action with body:
# {"os-retype": {"new_type": "<type>", "migration_policy": "<policy>"}}
self.conn.block_storage.retype_volume(
volume.id,
new_type=target_type.id,
migration_policy=migration_policy,
)
# ---- Optionally wait ----
if wait:
volume = self._wait_for_retype(volume.id, migration_policy, timeout)
else:
volume = self.conn.block_storage.get_volume(volume.id)
self.exit_json(
changed=True,
volume=volume.to_dict(),
)
def main():
module = VolumeRetypeModule()
module()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,12 @@
ansible-core>=2.19.0,<2.20.0
flake8
galaxy-importer
openstacksdk
pycodestyle
pylint
rstcheck
ruamel.yaml
tox
voluptuous
yamllint
setuptools

View File

@@ -0,0 +1,12 @@
ansible-core>=2.20.0,<2.21.0
flake8
galaxy-importer
openstacksdk
pycodestyle
pylint
rstcheck
ruamel.yaml
tox
voluptuous
yamllint
setuptools

View File

@@ -0,0 +1,339 @@
import importlib.util
from pathlib import Path
from unittest import mock
from ansible_collections.openstack.cloud.tests.unit.modules.utils import (
AnsibleExitJson,
AnsibleFailJson,
ModuleTestCase,
set_module_args,
)
def _load_module_under_test():
module_path = Path(__file__).resolve().parents[5] / 'plugins/modules/baremetal_port_group.py'
spec = importlib.util.spec_from_file_location('baremetal_port_group', str(module_path))
if spec is None or spec.loader is None:
raise ImportError('Cannot load baremetal_port_group module for tests')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
baremetal_port_group = _load_module_under_test()
class FakePortGroup(dict[str, object]):
def to_dict(self, computed=False):
return dict(self)
class FakeSDK(object):
class exceptions:
class OpenStackCloudException(Exception):
pass
class ResourceNotFound(Exception):
pass
class TestBaremetalPortGroup(ModuleTestCase):
module = baremetal_port_group
def setUp(self):
super(TestBaremetalPortGroup, self).setUp()
self.module = baremetal_port_group
def _run_module(self, module_args, baremetal):
set_module_args(module_args)
conn = mock.Mock()
conn.baremetal = baremetal
with mock.patch.object(
baremetal_port_group.BaremetalPortGroupModule,
'openstack_cloud_from_module',
return_value=(FakeSDK(), conn),
):
self.module.main()
def _new_baremetal(self):
baremetal = mock.Mock()
baremetal.find_port_group.return_value = None
baremetal.find_node.return_value = {'id': 'node-1'}
return baremetal
def test_create_port_group(self):
baremetal = self._new_baremetal()
baremetal.create_port_group.return_value = FakePortGroup(
id='pg-1',
name='bond0',
node_id='node-1',
address='fa:16:3e:aa:aa:aa',
mode='active-backup',
extra={},
properties={},
standalone_ports_supported=True,
links=[],
created_at='2026-01-01T00:00:00+00:00',
updated_at=None,
)
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'id': None,
'name': 'bond0',
'node': 'node-name',
'address': 'fa:16:3e:aa:aa:aa',
'extra': {},
'standalone_ports_supported': True,
'mode': 'active-backup',
'properties': {},
'state': 'present',
},
baremetal,
)
result = ex.exception.args[0]
self.assertTrue(result['changed'])
self.assertEqual('pg-1', result['port_group']['id'])
baremetal.find_node.assert_called_once_with('node-name', ignore_missing=False)
baremetal.create_port_group.assert_called_once_with(
name='bond0',
node_id='node-1',
address='fa:16:3e:aa:aa:aa',
extra={},
standalone_ports_supported=True,
mode='active-backup',
properties={},
)
def test_create_port_group_without_node_fails(self):
baremetal = self._new_baremetal()
with self.assertRaises(AnsibleFailJson) as ex:
self._run_module(
{
'id': None,
'name': 'bond0',
'node': None,
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': None,
'properties': None,
'state': 'present',
},
baremetal,
)
self.assertIn("Parameter 'node' is required", ex.exception.args[0]['msg'])
baremetal.create_port_group.assert_not_called()
def test_update_port_group_when_values_changed(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.return_value = FakePortGroup(
id='pg-1',
name='bond0',
node_id='node-1',
mode='active-backup',
address=None,
extra={},
properties={},
standalone_ports_supported=True,
links=[],
created_at='2026-01-01T00:00:00+00:00',
updated_at=None,
)
baremetal.update_port_group.return_value = FakePortGroup(
id='pg-1',
name='bond0',
node_id='node-1',
mode='802.3ad',
address=None,
extra={},
properties={},
standalone_ports_supported=True,
links=[],
created_at='2026-01-01T00:00:00+00:00',
updated_at='2026-01-02T00:00:00+00:00',
)
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'id': 'pg-1',
'name': None,
'node': None,
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': '802.3ad',
'properties': None,
'state': 'present',
},
baremetal,
)
result = ex.exception.args[0]
self.assertTrue(result['changed'])
self.assertEqual('802.3ad', result['port_group']['mode'])
baremetal.update_port_group.assert_called_once_with('pg-1', mode='802.3ad')
def test_present_noop_when_already_matching(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.return_value = FakePortGroup(
id='pg-1',
name='bond0',
node_id='node-1',
mode='active-backup',
address='fa:16:3e:aa:aa:aa',
extra={'a': 'b'},
properties={'miimon': '100'},
standalone_ports_supported=False,
links=[],
created_at='2026-01-01T00:00:00+00:00',
updated_at=None,
)
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'id': 'pg-1',
'name': 'bond0',
'node': None,
'address': 'fa:16:3e:aa:aa:aa',
'extra': {'a': 'b'},
'standalone_ports_supported': False,
'mode': 'active-backup',
'properties': {'miimon': '100'},
'state': 'present',
},
baremetal,
)
result = ex.exception.args[0]
self.assertFalse(result['changed'])
baremetal.update_port_group.assert_not_called()
def test_delete_existing_port_group(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.return_value = FakePortGroup(id='pg-1', name='bond0')
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'id': 'pg-1',
'name': None,
'node': None,
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': None,
'properties': None,
'state': 'absent',
},
baremetal,
)
result = ex.exception.args[0]
self.assertTrue(result['changed'])
baremetal.delete_port_group.assert_called_once_with('pg-1')
def test_delete_missing_port_group_is_noop(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.return_value = None
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'id': 'pg-1',
'name': None,
'node': None,
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': None,
'properties': None,
'state': 'absent',
},
baremetal,
)
result = ex.exception.args[0]
self.assertFalse(result['changed'])
baremetal.delete_port_group.assert_not_called()
def test_check_mode_create_marks_changed(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.return_value = None
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'_ansible_check_mode': True,
'id': None,
'name': 'bond0',
'node': 'node-name',
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': None,
'properties': None,
'state': 'present',
},
baremetal,
)
result = ex.exception.args[0]
self.assertTrue(result['changed'])
baremetal.create_port_group.assert_not_called()
baremetal.find_node.assert_called_once_with('node-name', ignore_missing=False)
def test_check_mode_create_without_node_fails(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.return_value = None
with self.assertRaises(AnsibleFailJson) as ex:
self._run_module(
{
'_ansible_check_mode': True,
'id': None,
'name': 'bond0',
'node': None,
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': None,
'properties': None,
'state': 'present',
},
baremetal,
)
self.assertIn("Parameter 'node' is required", ex.exception.args[0]['msg'])
baremetal.create_port_group.assert_not_called()
baremetal.find_node.assert_not_called()
def test_find_port_group_resource_not_found_returns_none(self):
baremetal = self._new_baremetal()
baremetal.find_port_group.side_effect = FakeSDK.exceptions.ResourceNotFound()
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module(
{
'id': 'pg-1',
'name': None,
'node': None,
'address': None,
'extra': None,
'standalone_ports_supported': None,
'mode': None,
'properties': None,
'state': 'absent',
},
baremetal,
)
result = ex.exception.args[0]
self.assertFalse(result['changed'])

View File

@@ -0,0 +1,97 @@
import unittest
from unittest import mock
from ansible_collections.openstack.cloud.plugins.modules import host_aggregate_info
from ansible_collections.openstack.cloud.tests.unit.modules.utils import (
AnsibleExitJson,
ModuleTestCase,
set_module_args,
)
class FakeAggregate(dict):
def to_dict(self, computed=False):
return dict(self)
FAKE_AGGREGATE = FakeAggregate(
id=7,
uuid='583bf5e7-b228-4850-ad3e-9a0b7aa6c8da',
name='dc1-a1',
availability_zone='dc1-a1',
hosts=['o3080-r01u07-1'],
metadata={'availability_zone': 'dc1-a1'},
created_at='2026-05-16T23:46:45.000000',
updated_at=None,
deleted_at=None,
is_deleted=False,
)
class FakeSDK(object):
class exceptions:
class OpenStackCloudException(Exception):
pass
class TestHostAggregateInfo(ModuleTestCase):
def _run_module(self, module_args, compute):
set_module_args(module_args)
conn = mock.Mock()
conn.compute = compute
with mock.patch.object(
host_aggregate_info.ComputeHostAggregateInfoModule,
'openstack_cloud_from_module',
return_value=(FakeSDK(), conn),
):
host_aggregate_info.main()
def _new_compute(self):
compute = mock.Mock()
compute.aggregates.return_value = [FAKE_AGGREGATE]
compute.find_aggregate.return_value = FAKE_AGGREGATE
return compute
def test_list_all_aggregates(self):
compute = self._new_compute()
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module({'name': None}, compute)
result = ex.exception.args[0]
self.assertFalse(result['changed'])
self.assertEqual(1, len(result['aggregates']))
self.assertEqual('dc1-a1', result['aggregates'][0]['name'])
compute.aggregates.assert_called_once_with()
compute.find_aggregate.assert_not_called()
def test_find_aggregate_by_name(self):
compute = self._new_compute()
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module({'name': 'dc1-a1'}, compute)
result = ex.exception.args[0]
self.assertFalse(result['changed'])
self.assertEqual(1, len(result['aggregates']))
self.assertEqual('dc1-a1', result['aggregates'][0]['name'])
compute.find_aggregate.assert_called_once_with('dc1-a1')
compute.aggregates.assert_not_called()
def test_find_aggregate_by_name_not_found(self):
compute = self._new_compute()
compute.find_aggregate.return_value = None
with self.assertRaises(AnsibleExitJson) as ex:
self._run_module({'name': 'nonexistent'}, compute)
result = ex.exception.args[0]
self.assertFalse(result['changed'])
self.assertEqual([], result['aggregates'])
compute.find_aggregate.assert_called_once_with('nonexistent')
if __name__ == '__main__':
unittest.main()

View File

@@ -14,6 +14,7 @@ def set_module_args(args):
args = json.dumps({'ANSIBLE_MODULE_ARGS': args})
basic._ANSIBLE_ARGS = to_bytes(args)
basic._ANSIBLE_PROFILE = 'legacy'
class AnsibleExitJson(Exception):

20
tox.ini
View File

@@ -2,12 +2,10 @@
minversion = 3.18.0
envlist = linters_latest,ansible_latest
skipsdist = True
ignore_basepython_conflict = True
[testenv]
skip_install = True
install_command = python3 -m pip install {opts} {packages}
basepython = python3
passenv =
OS_*
setenv =
@@ -43,7 +41,7 @@ commands =
ansible-galaxy collection build --force {toxinidir} --output-path {toxinidir}/build_artifact
bash {toxinidir}/tools/check-import.sh {toxinidir}
[testenv:linters_{2_9,2_11,2_12,2_16,2_18,latest}]
[testenv:linters_{2_18,2_19,2_20,latest}]
allowlist_externals = bash
commands =
{[testenv:build]commands}
@@ -54,11 +52,9 @@ deps =
-c{env:TOX_CONSTRAINTS_FILE:{toxinidir}/tests/constraints-none.txt}
{[testenv:build]deps}
linters_latest: -r{toxinidir}/tests/requirements.txt
linters_2_9: -r{toxinidir}/tests/requirements-ansible-2.9.txt
linters_2_11: -r{toxinidir}/tests/requirements-ansible-2.11.txt
linters_2_12: -r{toxinidir}/tests/requirements-ansible-2.12.txt
linters_2_16: -r{toxinidir}/tests/requirements-ansible-2.16.txt
linters_2_16: -r{toxinidir}/tests/requirements-ansible-2.18.txt
linters_2_18: -r{toxinidir}/tests/requirements-ansible-2.18.txt
linters_2_19: -r{toxinidir}/tests/requirements-ansible-2.19.txt
linters_2_20: -r{toxinidir}/tests/requirements-ansible-2.20.txt
passenv = *
[flake8]
@@ -72,18 +68,16 @@ ignore = W503,H4,E501,E402,H301
show-source = True
exclude=.venv,.git,.tox,dist,doc,*lib/python*,*egg,build,ansible_collections
[testenv:ansible_{2_9,2_11,2_12,2_16,2_18,latest}]
[testenv:ansible_{2_18,2_19,2_20,latest}]
allowlist_externals = bash
commands =
bash {toxinidir}/ci/run-ansible-tests-collection.sh -e {envdir} {posargs}
deps =
-c{env:TOX_CONSTRAINTS_FILE:{toxinidir}/tests/constraints-none.txt}
ansible_latest: -r{toxinidir}/tests/requirements.txt
ansible_2_9: -r{toxinidir}/tests/requirements-ansible-2.9.txt
ansible_2_11: -r{toxinidir}/tests/requirements-ansible-2.11.txt
ansible_2_12: -r{toxinidir}/tests/requirements-ansible-2.12.txt
ansible_2_16: -r{toxinidir}/tests/requirements-ansible-2.16.txt
ansible_2_18: -r{toxinidir}/tests/requirements-ansible-2.18.txt
ansible_2_19: -r{toxinidir}/tests/requirements-ansible-2.19.txt
ansible_2_20: -r{toxinidir}/tests/requirements-ansible-2.20.txt
# Need to pass some env vars for the Ansible playbooks
passenv =
HOME