diff --git a/.azure-pipelines/azure-pipelines.yml b/.azure-pipelines/azure-pipelines.yml index 163d71b628..8db5107f4c 100644 --- a/.azure-pipelines/azure-pipelines.yml +++ b/.azure-pipelines/azure-pipelines.yml @@ -29,14 +29,14 @@ schedules: always: true branches: include: + - stable-9 - stable-8 - - stable-7 - cron: 0 11 * * 0 displayName: Weekly (old stable branches) always: true branches: include: - - stable-6 + - stable-7 variables: - name: checkoutPath @@ -53,7 +53,7 @@ variables: resources: containers: - container: default - image: quay.io/ansible/azure-pipelines-test-container:4.0.1 + image: quay.io/ansible/azure-pipelines-test-container:6.0.0 pool: Standard @@ -73,6 +73,19 @@ stages: - test: 3 - test: 4 - test: extra + - stage: Sanity_2_17 + displayName: Sanity 2.17 + dependsOn: [] + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: Test {0} + testFormat: 2.17/sanity/{0} + targets: + - test: 1 + - test: 2 + - test: 3 + - test: 4 - stage: Sanity_2_16 displayName: Sanity 2.16 dependsOn: [] @@ -99,19 +112,6 @@ stages: - test: 2 - test: 3 - test: 4 - - stage: Sanity_2_14 - displayName: Sanity 2.14 - dependsOn: [] - jobs: - - template: templates/matrix.yml - parameters: - nameFormat: Test {0} - testFormat: 2.14/sanity/{0} - targets: - - test: 1 - - test: 2 - - test: 3 - - test: 4 ### Units - stage: Units_devel displayName: Units devel @@ -122,12 +122,23 @@ stages: nameFormat: Python {0} testFormat: devel/units/{0}/1 targets: - - test: 3.7 - test: 3.8 - test: 3.9 - test: '3.10' - test: '3.11' - test: '3.12' + - test: '3.13' + - stage: Units_2_17 + displayName: Units 2.17 + dependsOn: [] + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: Python {0} + testFormat: 2.17/units/{0}/1 + targets: + - test: 3.7 + - test: "3.12" - stage: Units_2_16 displayName: Units 2.16 dependsOn: [] @@ -151,16 +162,6 @@ stages: targets: - test: 3.5 - test: "3.10" - - stage: Units_2_14 - displayName: Units 2.14 - dependsOn: [] - jobs: - - template: templates/matrix.yml - parameters: - nameFormat: Python {0} - testFormat: 2.14/units/{0}/1 - targets: - - test: 3.9 ## Remote - stage: Remote_devel_extra_vms @@ -171,12 +172,14 @@ stages: parameters: testFormat: devel/{0} targets: - - name: Alpine 3.19 - test: alpine/3.19 - # - name: Fedora 39 - # test: fedora/39 + - name: Alpine 3.20 + test: alpine/3.20 + # - name: Fedora 40 + # test: fedora/40 - name: Ubuntu 22.04 test: ubuntu/22.04 + - name: Ubuntu 24.04 + test: ubuntu/24.04 groups: - vm - stage: Remote_devel @@ -189,10 +192,26 @@ stages: targets: - name: macOS 14.3 test: macos/14.3 - - name: RHEL 9.3 - test: rhel/9.3 + - name: RHEL 9.4 + test: rhel/9.4 + - name: FreeBSD 14.1 + test: freebsd/14.1 + groups: + - 1 + - 2 + - 3 + - stage: Remote_2_17 + displayName: Remote 2.17 + dependsOn: [] + jobs: + - template: templates/matrix.yml + parameters: + testFormat: 2.17/{0} + targets: - name: FreeBSD 13.3 test: freebsd/13.3 + - name: RHEL 9.3 + test: rhel/9.3 - name: FreeBSD 14.0 test: freebsd/14.0 groups: @@ -213,8 +232,8 @@ stages: test: rhel/9.2 - name: RHEL 8.8 test: rhel/8.8 - - name: FreeBSD 13.2 - test: freebsd/13.2 + # - name: FreeBSD 13.2 + # test: freebsd/13.2 groups: - 1 - 2 @@ -241,24 +260,6 @@ stages: - 1 - 2 - 3 - - stage: Remote_2_14 - displayName: Remote 2.14 - dependsOn: [] - jobs: - - template: templates/matrix.yml - parameters: - testFormat: 2.14/{0} - targets: - #- name: macOS 12.0 - # test: macos/12.0 - - name: RHEL 9.0 - test: rhel/9.0 - #- name: FreeBSD 12.4 - # test: freebsd/12.4 - groups: - - 1 - - 2 - - 3 ### Docker - stage: Docker_devel @@ -269,14 +270,32 @@ stages: parameters: testFormat: devel/linux/{0} targets: - - name: Fedora 39 - test: fedora39 - - name: Ubuntu 20.04 - test: ubuntu2004 + - name: Fedora 40 + test: fedora40 + - name: Alpine 3.20 + test: alpine320 - name: Ubuntu 22.04 test: ubuntu2204 + - name: Ubuntu 24.04 + test: ubuntu2404 + groups: + - 1 + - 2 + - 3 + - stage: Docker_2_17 + displayName: Docker 2.17 + dependsOn: [] + jobs: + - template: templates/matrix.yml + parameters: + testFormat: 2.17/linux/{0} + targets: + - name: Fedora 39 + test: fedora39 - name: Alpine 3.19 test: alpine319 + - name: Ubuntu 20.04 + test: ubuntu2004 groups: - 1 - 2 @@ -315,20 +334,6 @@ stages: - 1 - 2 - 3 - - stage: Docker_2_14 - displayName: Docker 2.14 - dependsOn: [] - jobs: - - template: templates/matrix.yml - parameters: - testFormat: 2.14/linux/{0} - targets: - - name: Alpine 3 - test: alpine3 - groups: - - 1 - - 2 - - 3 ### Community Docker - stage: Docker_community_devel @@ -344,7 +349,7 @@ stages: - name: Debian Bookworm test: debian-bookworm/3.11 - name: ArchLinux - test: archlinux/3.11 + test: archlinux/3.12 groups: - 1 - 2 @@ -359,6 +364,18 @@ stages: parameters: nameFormat: Python {0} testFormat: devel/generic/{0}/1 + targets: + - test: '3.8' + - test: '3.11' + - test: '3.13' + - stage: Generic_2_17 + displayName: Generic 2.17 + dependsOn: [] + jobs: + - template: templates/matrix.yml + parameters: + nameFormat: Python {0} + testFormat: 2.17/generic/{0}/1 targets: - test: '3.7' - test: '3.12' @@ -384,42 +401,32 @@ stages: testFormat: 2.15/generic/{0}/1 targets: - test: '3.9' - - stage: Generic_2_14 - displayName: Generic 2.14 - dependsOn: [] - jobs: - - template: templates/matrix.yml - parameters: - nameFormat: Python {0} - testFormat: 2.14/generic/{0}/1 - targets: - - test: '3.10' - stage: Summary condition: succeededOrFailed() dependsOn: - Sanity_devel + - Sanity_2_17 - Sanity_2_16 - Sanity_2_15 - - Sanity_2_14 - Units_devel + - Units_2_17 - Units_2_16 - Units_2_15 - - Units_2_14 - Remote_devel_extra_vms - Remote_devel + - Remote_2_17 - Remote_2_16 - Remote_2_15 - - Remote_2_14 - Docker_devel + - Docker_2_17 - Docker_2_16 - Docker_2_15 - - Docker_2_14 - Docker_community_devel # Right now all generic tests are disabled. Uncomment when at least one of them is re-enabled. # - Generic_devel +# - Generic_2_17 # - Generic_2_16 # - Generic_2_15 -# - Generic_2_14 jobs: - template: templates/coverage.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 64cbc7021b..bc34755b31 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -33,6 +33,8 @@ files: maintainers: $team_ansible_core $becomes/pmrun.py: maintainers: $team_ansible_core + $becomes/run0.py: + maintainers: konstruktoid $becomes/sesu.py: maintainers: nekonyuu $becomes/sudosu.py: @@ -89,6 +91,8 @@ files: maintainers: ryancurrah $callbacks/syslog_json.py: maintainers: imjoseangel + $callbacks/timestamp.py: + maintainers: kurokobo $callbacks/unixy.py: labels: unixy maintainers: akatch @@ -117,6 +121,8 @@ files: maintainers: $team_ansible_core $doc_fragments/: labels: docs_fragments + $doc_fragments/django.py: + maintainers: russoz $doc_fragments/hpe3par.py: labels: hpe3par maintainers: farhan7500 gautamphegde @@ -151,6 +157,8 @@ files: $filters/jc.py: maintainers: kellyjonbrazil $filters/json_query.py: {} + $filters/keep_keys.py: + maintainers: vbotka $filters/lists.py: maintainers: cfiehe $filters/lists_difference.yml: @@ -164,6 +172,12 @@ files: $filters/lists_union.yml: maintainers: cfiehe $filters/random_mac.py: {} + $filters/remove_keys.py: + maintainers: vbotka + $filters/replace_keys.py: + maintainers: vbotka + $filters/reveal_ansible_type.py: + maintainers: vbotka $filters/time.py: maintainers: resmo $filters/to_days.yml: @@ -294,8 +308,12 @@ files: labels: module_utils $module_utils/btrfs.py: maintainers: gnfzdz + $module_utils/cmd_runner.py: + maintainers: russoz $module_utils/deps.py: maintainers: russoz + $module_utils/django.py: + maintainers: russoz $module_utils/gconftool2.py: labels: gconftool2 maintainers: russoz @@ -339,6 +357,8 @@ files: $module_utils/pipx.py: labels: pipx maintainers: russoz + $module_utils/python_runner.py: + maintainers: russoz $module_utils/puppet.py: labels: puppet maintainers: russoz @@ -428,6 +448,8 @@ files: maintainers: hkariti $modules/bitbucket_: maintainers: catcombo + $modules/bootc_manage.py: + maintainers: cooktheryan $modules/bower.py: maintainers: mwarkentin $modules/btrfs_: @@ -490,6 +512,12 @@ files: maintainers: tintoy $modules/discord.py: maintainers: cwollinger + $modules/django_check.py: + maintainers: russoz + $modules/django_command.py: + maintainers: russoz + $modules/django_createcachetable.py: + maintainers: russoz $modules/django_manage.py: ignore: scottanderson42 tastychutney labels: django_manage @@ -532,8 +560,6 @@ files: maintainers: $team_flatpak $modules/flatpak_remote.py: maintainers: $team_flatpak - $modules/flowdock.py: - ignore: mcodd $modules/gandi_livedns.py: maintainers: gthiemonge $modules/gconftool2.py: @@ -622,6 +648,11 @@ files: labels: homebrew_ macos maintainers: $team_macos notify: chris-short + $modules/homebrew_services.py: + ignore: ryansb + keywords: brew cask services darwin homebrew macosx macports osx + labels: homebrew_ macos + maintainers: $team_macos kitizz $modules/homectl.py: maintainers: jameslivulpi $modules/honeybadger_deployment.py: @@ -778,8 +809,12 @@ files: maintainers: elfelip $modules/keycloak_user_federation.py: maintainers: laurpaum + $modules/keycloak_userprofile.py: + maintainers: yeoldegrove $modules/keycloak_component_info.py: maintainers: desand01 + $modules/keycloak_client_rolescope.py: + maintainers: desand01 $modules/keycloak_user_rolemapping.py: maintainers: bratwurzt $modules/keycloak_realm_rolemapping.py: @@ -941,6 +976,8 @@ files: maintainers: $team_opennebula $modules/one_host.py: maintainers: rvalle + $modules/one_vnet.py: + maintainers: abakanovskii $modules/oneandone_: maintainers: aajdinov edevenport $modules/onepassword_info.py: @@ -1094,46 +1131,6 @@ files: $modules/python_requirements_info.py: ignore: ryansb maintainers: willthames - $modules/rax: - ignore: ryansb sivel - $modules/rax.py: - maintainers: omgjlk sivel - $modules/rax_cbs.py: - maintainers: claco - $modules/rax_cbs_attachments.py: - maintainers: claco - $modules/rax_cdb.py: - maintainers: jails - $modules/rax_cdb_database.py: - maintainers: jails - $modules/rax_cdb_user.py: - maintainers: jails - $modules/rax_clb.py: - maintainers: claco - $modules/rax_clb_nodes.py: - maintainers: neuroid - $modules/rax_clb_ssl.py: - maintainers: smashwilson - $modules/rax_files.py: - maintainers: angstwad - $modules/rax_files_objects.py: - maintainers: angstwad - $modules/rax_identity.py: - maintainers: claco - $modules/rax_mon_alarm.py: - maintainers: smashwilson - $modules/rax_mon_check.py: - maintainers: smashwilson - $modules/rax_mon_entity.py: - maintainers: smashwilson - $modules/rax_mon_notification.py: - maintainers: smashwilson - $modules/rax_mon_notification_plan.py: - maintainers: smashwilson - $modules/rax_network.py: - maintainers: claco omgjlk - $modules/rax_queue.py: - maintainers: claco $modules/read_csv.py: maintainers: dagwieers $modules/redfish_: @@ -1298,8 +1295,6 @@ files: maintainers: farhan7500 gautamphegde $modules/ssh_config.py: maintainers: gaqzi Akasurde - $modules/stackdriver.py: - maintainers: bwhaley $modules/stacki_host.py: labels: stacki_host maintainers: bsanders bbyhuy @@ -1392,8 +1387,6 @@ files: maintainers: $team_wdc $modules/wdc_redfish_info.py: maintainers: $team_wdc - $modules/webfaction_: - maintainers: quentinsf $modules/xattr.py: labels: xattr maintainers: bcoca @@ -1445,8 +1438,16 @@ files: ignore: matze labels: zypper maintainers: $team_suse + $plugin_utils/ansible_type.py: + maintainers: vbotka + $plugin_utils/keys_filter.py: + maintainers: vbotka + $plugin_utils/unsafe.py: + maintainers: felixfontein $tests/a_module.py: maintainers: felixfontein + $tests/ansible_type.py: + maintainers: vbotka $tests/fqdn_valid.py: maintainers: vbotka ######################### @@ -1460,6 +1461,14 @@ files: maintainers: felixfontein docs/docsite/rst/filter_guide_abstract_informations_lists_helper.rst: maintainers: cfiehe + docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst: + maintainers: vbotka + docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst: + maintainers: vbotka + docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst: + maintainers: vbotka + docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries.rst: + maintainers: vbotka docs/docsite/rst/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst: maintainers: vbotka docs/docsite/rst/filter_guide_conversions.rst: @@ -1476,12 +1485,20 @@ files: maintainers: ericzolf docs/docsite/rst/guide_alicloud.rst: maintainers: xiaozhu36 + docs/docsite/rst/guide_cmdrunner.rst: + maintainers: russoz + docs/docsite/rst/guide_deps.rst: + maintainers: russoz + docs/docsite/rst/guide_modulehelper.rst: + maintainers: russoz docs/docsite/rst/guide_online.rst: maintainers: remyleone docs/docsite/rst/guide_packet.rst: maintainers: baldwinSPC nurfet-becirevic t0mk teebes docs/docsite/rst/guide_scaleway.rst: maintainers: $team_scaleway + docs/docsite/rst/guide_vardict.rst: + maintainers: russoz docs/docsite/rst/test_guide.rst: maintainers: felixfontein ######################### @@ -1501,7 +1518,6 @@ macros: becomes: plugins/become caches: plugins/cache callbacks: plugins/callback - cliconfs: plugins/cliconf connections: plugins/connection doc_fragments: plugins/doc_fragments filters: plugins/filter @@ -1509,12 +1525,12 @@ macros: lookups: plugins/lookup module_utils: plugins/module_utils modules: plugins/modules - terminals: plugins/terminal + plugin_utils: plugins/plugin_utils tests: plugins/test team_ansible_core: team_aix: MorrisA bcoca d-little flynn1973 gforster kairoaraujo marvin-sinister mator molekuul ramooncamacho wtcross team_bsd: JoergFiedler MacLemon bcoca dch jasperla mekanix opoplawski overhacked tuxillo - team_consul: sgargan apollo13 + team_consul: sgargan apollo13 Ilgmi team_cyberark_conjur: jvanderhoof ryanprior team_e_spirit: MatrixCrawler getjack team_flatpak: JayKayy oolongbrothers @@ -1523,7 +1539,7 @@ macros: team_huawei: QijunPan TommyLike edisonxiang freesky-edward hwDCN niuzhenguo xuxiaowei0512 yanzhangi zengchen1024 zhongjun2 team_ipa: Akasurde Nosmoht justchris1 team_jboss: Wolfant jairojunior wbrefvem - team_keycloak: eikef ndclt mattock + team_keycloak: eikef ndclt mattock thomasbach-dev team_linode: InTheCloudDan decentral1se displague rmcintosh Charliekenney23 LBGarber team_macos: Akasurde kyleabenson martinm82 danieljaouen indrajitr team_manageiq: abellotti cben gtanzillo yaacov zgalor dkorn evertmulder diff --git a/.github/workflows/ansible-test.yml b/.github/workflows/ansible-test.yml index bc9daaa43e..e57213e9fa 100644 --- a/.github/workflows/ansible-test.yml +++ b/.github/workflows/ansible-test.yml @@ -30,6 +30,7 @@ jobs: matrix: ansible: - '2.13' + - '2.14' # Ansible-test on various stable branches does not yet work well with cgroups v2. # Since ubuntu-latest now uses Ubuntu 22.04, we need to fall back to the ubuntu-20.04 # image for these stable branches. The list of branches where this is necessary will @@ -41,6 +42,7 @@ jobs: uses: felixfontein/ansible-test-gh-action@main with: ansible-core-version: stable-${{ matrix.ansible }} + codecov-token: ${{ secrets.CODECOV_TOKEN }} coverage: ${{ github.event_name == 'schedule' && 'always' || 'never' }} pull-request-change-detection: 'true' testing-type: sanity @@ -72,6 +74,8 @@ jobs: python: '2.7' - ansible: '2.13' python: '3.8' + - ansible: '2.14' + python: '3.9' steps: - name: >- @@ -80,6 +84,7 @@ jobs: uses: felixfontein/ansible-test-gh-action@main with: ansible-core-version: stable-${{ matrix.ansible }} + codecov-token: ${{ secrets.CODECOV_TOKEN }} coverage: ${{ github.event_name == 'schedule' && 'always' || 'never' }} pre-test-cmd: >- mkdir -p ../../ansible @@ -148,11 +153,29 @@ jobs: docker: alpine3 python: '' target: azp/posix/3/ + # 2.14 + - ansible: '2.14' + docker: alpine3 + python: '' + target: azp/posix/1/ + - ansible: '2.14' + docker: alpine3 + python: '' + target: azp/posix/2/ + - ansible: '2.14' + docker: alpine3 + python: '' + target: azp/posix/3/ # Right now all generic tests are disabled. Uncomment when at least one of them is re-enabled. # - ansible: '2.13' # docker: default # python: '3.9' # target: azp/generic/1/ + # Right now all generic tests are disabled. Uncomment when at least one of them is re-enabled. + # - ansible: '2.14' + # docker: default + # python: '3.10' + # target: azp/generic/1/ steps: - name: >- @@ -162,6 +185,7 @@ jobs: uses: felixfontein/ansible-test-gh-action@main with: ansible-core-version: stable-${{ matrix.ansible }} + codecov-token: ${{ secrets.CODECOV_TOKEN }} coverage: ${{ github.event_name == 'schedule' && 'always' || 'never' }} docker-image: ${{ matrix.docker }} integration-continue-on-error: 'false' diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index 031e94cb7a..31afe207c5 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -27,4 +27,4 @@ jobs: ref: ${{ github.event.pull_request.head.sha || '' }} - name: REUSE Compliance Check - uses: fsfe/reuse-action@v3 + uses: fsfe/reuse-action@v4 diff --git a/.gitignore b/.gitignore index b7868a9e41..cf1f74e41c 100644 --- a/.gitignore +++ b/.gitignore @@ -512,3 +512,7 @@ $RECYCLE.BIN/ # Integration tests cloud configs tests/integration/cloud-config-*.ini + + +# VSCode specific extensions +.vscode/settings.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 199e90c5b1..55a7098cc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,9 @@ Also, consider taking up a valuable, reviewed, but abandoned pull request which * Try committing your changes with an informative but short commit message. * Do not squash your commits and force-push to your branch if not needed. Reviews of your pull request are much easier with individual commits to comprehend the pull request history. All commits of your pull request branch will be squashed into one commit by GitHub upon merge. * Do not add merge commits to your PR. The bot will complain and you will have to rebase ([instructions for rebasing](https://docs.ansible.com/ansible/latest/dev_guide/developing_rebasing.html)) to remove them before your PR can be merged. To avoid that git automatically does merges during pulls, you can configure it to do rebases instead by running `git config pull.rebase true` inside the repository checkout. -* Make sure your PR includes a [changelog fragment](https://docs.ansible.com/ansible/devel/community/development_process.html#creating-changelog-fragments). (You must not include a fragment for new modules or new plugins. Also you shouldn't include one for docs-only changes. If you're not sure, simply don't include one, we'll tell you whether one is needed or not :) ) +* Make sure your PR includes a [changelog fragment](https://docs.ansible.com/ansible/devel/community/collection_development_process.html#creating-a-changelog-fragment). + * You must not include a fragment for new modules or new plugins. Also you shouldn't include one for docs-only changes. (If you're not sure, simply don't include one, we'll tell you whether one is needed or not :) ) + * Please always include a link to the pull request itself, and if the PR is about an issue, also a link to the issue. Also make sure the fragment ends with a period, and begins with a lower-case letter after `-`. (Again, if you don't do this, we'll add suggestions to fix it, so don't worry too much :) ) * Avoid reformatting unrelated parts of the codebase in your PR. These types of changes will likely be requested for reversion, create additional work for reviewers, and may cause approval to be delayed. You can also read [our Quick-start development guide](https://github.com/ansible/community-docs/blob/main/create_pr_quick_start_guide.rst). @@ -54,6 +56,8 @@ cd ~/dev/ansible_collections/community/general Then you can run `ansible-test` (which is a part of [ansible-core](https://pypi.org/project/ansible-core/)) inside the checkout. The following example commands expect that you have installed Docker or Podman. Note that Podman has only been supported by more recent ansible-core releases. If you are using Docker, the following will work with Ansible 2.9+. +### Sanity tests + The following commands show how to run sanity tests: ```.bash @@ -64,6 +68,8 @@ ansible-test sanity --docker -v ansible-test sanity --docker -v plugins/modules/system/pids.py tests/integration/targets/pids/ ``` +### Unit tests + The following commands show how to run unit tests: ```.bash @@ -77,13 +83,32 @@ ansible-test units --docker -v --python 3.8 ansible-test units --docker -v --python 3.8 tests/unit/plugins/modules/net_tools/test_nmcli.py ``` +### Integration tests + The following commands show how to run integration tests: -```.bash -# Run integration tests for the interfaces_files module in a Docker container using the -# fedora35 operating system image (the supported images depend on your ansible-core version): -ansible-test integration --docker fedora35 -v interfaces_file +#### In Docker +Integration tests on Docker have the following parameters: +- `image_name` (required): The name of the Docker image. To get the list of supported Docker images, run + `ansible-test integration --help` and look for _target docker images_. +- `test_name` (optional): The name of the integration test. + For modules, this equals the short name of the module; for example, `pacman` in case of `community.general.pacman`. + For plugins, the plugin type is added before the plugin's short name, for example `callback_yaml` for the `community.general.yaml` callback. +```.bash +# Test all plugins/modules on fedora40 +ansible-test integration -v --docker fedora40 + +# Template +ansible-test integration -v --docker image_name test_name + +# Example community.general.ini_file module on fedora40 Docker image: +ansible-test integration -v --docker fedora40 ini_file +``` + +#### Without isolation + +```.bash # Run integration tests for the flattened lookup **without any isolation**: ansible-test integration -v lookup_flattened ``` diff --git a/README.md b/README.md index 96b3c952db..53354b93f9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later [![Build Status](https://dev.azure.com/ansible/community.general/_apis/build/status/CI?branchName=main)](https://dev.azure.com/ansible/community.general/_build?definitionId=31) [![EOL CI](https://github.com/ansible-collections/community.general/workflows/EOL%20CI/badge.svg?event=push)](https://github.com/ansible-collections/community.general/actions) [![Codecov](https://img.shields.io/codecov/c/github/ansible-collections/community.general)](https://codecov.io/gh/ansible-collections/community.general) +[![REUSE status](https://api.reuse.software/badge/github.com/ansible-collections/community.general)](https://api.reuse.software/info/github.com/ansible-collections/community.general) This repository contains the `community.general` Ansible Collection. The collection is a part of the Ansible package and includes many modules and plugins supported by Ansible community which are not part of more specialized community collections. @@ -22,9 +23,21 @@ We follow [Ansible Code of Conduct](https://docs.ansible.com/ansible/latest/comm If you encounter abusive behavior violating the [Ansible Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html), please refer to the [policy violations](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html#policy-violations) section of the Code of Conduct for information on how to raise a complaint. +## Communication + +* Join the Ansible forum: + * [Get Help](https://forum.ansible.com/c/help/6): get help or help others. This is for questions about modules or plugins in the collection. Please add appropriate tags if you start new discussions. + * [Tag `community-general`](https://forum.ansible.com/tag/community-general): discuss the *collection itself*, instead of specific modules or plugins. + * [Social Spaces](https://forum.ansible.com/c/chat/4): gather and interact with fellow enthusiasts. + * [News & Announcements](https://forum.ansible.com/c/news/5): track project-wide announcements including social events. + +* The Ansible [Bullhorn newsletter](https://docs.ansible.com/ansible/devel/community/communication.html#the-bullhorn): used to announce releases and important changes. + +For more information about communication, see the [Ansible communication guide](https://docs.ansible.com/ansible/devel/community/communication.html). + ## Tested with Ansible -Tested with the current ansible-core 2.13, ansible-core 2.14, ansible-core 2.15, ansible-core 2.16 releases and the current development version of ansible-core. Ansible-core versions before 2.13.0 are not supported. This includes all ansible-base 2.10 and Ansible 2.9 releases. +Tested with the current ansible-core 2.13, ansible-core 2.14, ansible-core 2.15, ansible-core 2.16, ansible-core 2.17 releases and the current development version of ansible-core. Ansible-core versions before 2.13.0 are not supported. This includes all ansible-base 2.10 and Ansible 2.9 releases. ## External requirements @@ -97,18 +110,6 @@ It is necessary for maintainers of this collection to be subscribed to: They also should be subscribed to Ansible's [The Bullhorn newsletter](https://docs.ansible.com/ansible/devel/community/communication.html#the-bullhorn). -## Communication - -We announce important development changes and releases through Ansible's [The Bullhorn newsletter](https://eepurl.com/gZmiEP). If you are a collection developer, be sure you are subscribed. - -Join us in the `#ansible` (general use questions and support), `#ansible-community` (community and collection development questions), and other [IRC channels](https://docs.ansible.com/ansible/devel/community/communication.html#irc-channels) on [Libera.chat](https://libera.chat). - -We take part in the global quarterly [Ansible Contributor Summit](https://github.com/ansible/community/wiki/Contributor-Summit) virtually or in-person. Track [The Bullhorn newsletter](https://eepurl.com/gZmiEP) and join us. - -For more information about communities, meetings and agendas see [Community Wiki](https://github.com/ansible/community/wiki/Community). - -For more information about communication, refer to Ansible's the [Communication guide](https://docs.ansible.com/ansible/devel/community/communication.html). - ## Publishing New Version See the [Releasing guidelines](https://github.com/ansible/community-docs/blob/main/releasing_collections.rst) to learn how to release this collection. diff --git a/changelogs/changelog.yaml b/changelogs/changelog.yaml index 02bd8e7803..5aa97d97e9 100644 --- a/changelogs/changelog.yaml +++ b/changelogs/changelog.yaml @@ -1,2 +1,3 @@ -ancestor: 8.0.0 +--- +ancestor: 9.0.0 releases: {} diff --git a/changelogs/config.yaml b/changelogs/config.yaml index 23afe36d29..32ffe27f2b 100644 --- a/changelogs/config.yaml +++ b/changelogs/config.yaml @@ -18,20 +18,25 @@ output_formats: prelude_section_name: release_summary prelude_section_title: Release Summary sections: -- - major_changes - - Major Changes -- - minor_changes - - Minor Changes -- - breaking_changes - - Breaking Changes / Porting Guide -- - deprecated_features - - Deprecated Features -- - removed_features - - Removed Features (previously deprecated) -- - security_fixes - - Security Fixes -- - bugfixes - - Bugfixes -- - known_issues - - Known Issues + - - major_changes + - Major Changes + - - minor_changes + - Minor Changes + - - breaking_changes + - Breaking Changes / Porting Guide + - - deprecated_features + - Deprecated Features + - - removed_features + - Removed Features (previously deprecated) + - - security_fixes + - Security Fixes + - - bugfixes + - Bugfixes + - - known_issues + - Known Issues title: Community General +trivial_section_name: trivial +use_fqcn: true +add_plugin_period: true +changelog_nice_yaml: true +changelog_sort: version diff --git a/changelogs/fragments/000-redhat_subscription-dbus-on-7.4-plus.yaml b/changelogs/fragments/000-redhat_subscription-dbus-on-7.4-plus.yaml deleted file mode 100644 index 64390308d7..0000000000 --- a/changelogs/fragments/000-redhat_subscription-dbus-on-7.4-plus.yaml +++ /dev/null @@ -1,6 +0,0 @@ -bugfixes: - - | - redhat_subscription - use the D-Bus registration on RHEL 7 only on 7.4 and - greater; older versions of RHEL 7 do not have it - (https://github.com/ansible-collections/community.general/issues/7622, - https://github.com/ansible-collections/community.general/pull/7624). diff --git a/changelogs/fragments/5588-support-1password-connect.yml b/changelogs/fragments/5588-support-1password-connect.yml deleted file mode 100644 index bec2300d3f..0000000000 --- a/changelogs/fragments/5588-support-1password-connect.yml +++ /dev/null @@ -1,3 +0,0 @@ -minor_changes: - - onepassword lookup plugin - support 1Password Connect with the opv2 client by setting the connect_host and connect_token parameters (https://github.com/ansible-collections/community.general/pull/7116). - - onepassword_raw lookup plugin - support 1Password Connect with the opv2 client by setting the connect_host and connect_token parameters (https://github.com/ansible-collections/community.general/pull/7116) diff --git a/changelogs/fragments/6572-nmcli-add-support-loopback-type.yml b/changelogs/fragments/6572-nmcli-add-support-loopback-type.yml deleted file mode 100644 index 4382851d68..0000000000 --- a/changelogs/fragments/6572-nmcli-add-support-loopback-type.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - nmcli - add support for new connection type ``loopback`` (https://github.com/ansible-collections/community.general/issues/6572). diff --git a/changelogs/fragments/7143-proxmox-template.yml b/changelogs/fragments/7143-proxmox-template.yml deleted file mode 100644 index 89d44594d3..0000000000 --- a/changelogs/fragments/7143-proxmox-template.yml +++ /dev/null @@ -1,3 +0,0 @@ -minor_changes: - - proxmox - adds ``template`` value to the ``state`` parameter, allowing conversion of container to a template (https://github.com/ansible-collections/community.general/pull/7143). - - proxmox_kvm - adds ``template`` value to the ``state`` parameter, allowing conversion of a VM to a template (https://github.com/ansible-collections/community.general/pull/7143). diff --git a/changelogs/fragments/7151-fix-keycloak_authz_permission-incorrect-resource-payload.yml b/changelogs/fragments/7151-fix-keycloak_authz_permission-incorrect-resource-payload.yml deleted file mode 100644 index 2fa50a47ee..0000000000 --- a/changelogs/fragments/7151-fix-keycloak_authz_permission-incorrect-resource-payload.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - keycloak_authz_permission - resource payload variable for scope-based permission was constructed as a string, when it needs to be a list, even for a single item (https://github.com/ansible-collections/community.general/issues/7151). diff --git a/changelogs/fragments/7199-gitlab-runner-new-creation-workflow.yml b/changelogs/fragments/7199-gitlab-runner-new-creation-workflow.yml deleted file mode 100644 index d4c5f96f9d..0000000000 --- a/changelogs/fragments/7199-gitlab-runner-new-creation-workflow.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - gitlab_runner - add support for new runner creation workflow (https://github.com/ansible-collections/community.general/pull/7199). diff --git a/changelogs/fragments/7242-multi-values-for-same-name-in-git-config.yml b/changelogs/fragments/7242-multi-values-for-same-name-in-git-config.yml deleted file mode 100644 index be3dfdcac9..0000000000 --- a/changelogs/fragments/7242-multi-values-for-same-name-in-git-config.yml +++ /dev/null @@ -1,4 +0,0 @@ -minor_changes: - - "git_config - allow multiple git configs for the same name with the new ``add_mode`` option (https://github.com/ansible-collections/community.general/pull/7260)." - - "git_config - the ``after`` and ``before`` fields in the ``diff`` of the return value can be a list instead of a string in case more configs with the same key are affected (https://github.com/ansible-collections/community.general/pull/7260)." - - "git_config - when a value is unset, all configs with the same key are unset (https://github.com/ansible-collections/community.general/pull/7260)." diff --git a/changelogs/fragments/7389-nmcli-issue-with-creating-a-wifi-bridge-slave.yml b/changelogs/fragments/7389-nmcli-issue-with-creating-a-wifi-bridge-slave.yml deleted file mode 100644 index f5f07dc230..0000000000 --- a/changelogs/fragments/7389-nmcli-issue-with-creating-a-wifi-bridge-slave.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - nmcli - fix ``connection.slave-type`` wired to ``bond`` and not with parameter ``slave_type`` in case of connection type ``wifi`` (https://github.com/ansible-collections/community.general/issues/7389). \ No newline at end of file diff --git a/changelogs/fragments/7418-kc_identity_provider-mapper-reconfiguration-fixes.yml b/changelogs/fragments/7418-kc_identity_provider-mapper-reconfiguration-fixes.yml deleted file mode 100644 index 30f3673499..0000000000 --- a/changelogs/fragments/7418-kc_identity_provider-mapper-reconfiguration-fixes.yml +++ /dev/null @@ -1,3 +0,0 @@ -bugfixes: - - keycloak_identity_provider - it was not possible to reconfigure (add, remove) ``mappers`` once they were created initially. Removal was ignored, adding new ones resulted in dropping the pre-existing unmodified mappers. Fix resolves the issue by supplying correct input to the internal update call (https://github.com/ansible-collections/community.general/pull/7418). - - keycloak_identity_provider - ``mappers`` processing was not idempotent if the mappers configuration list had not been sorted by name (in ascending order). Fix resolves the issue by sorting mappers in the desired state using the same key which is used for obtaining existing state (https://github.com/ansible-collections/community.general/pull/7418). \ No newline at end of file diff --git a/changelogs/fragments/7426-add-timestamp-and-preserve-options-for-passwordstore.yaml b/changelogs/fragments/7426-add-timestamp-and-preserve-options-for-passwordstore.yaml deleted file mode 100644 index 59e22b450f..0000000000 --- a/changelogs/fragments/7426-add-timestamp-and-preserve-options-for-passwordstore.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - passwordstore - adds ``timestamp`` and ``preserve`` parameters to modify the stored password format (https://github.com/ansible-collections/community.general/pull/7426). \ No newline at end of file diff --git a/changelogs/fragments/7456-add-ssh-control-master.yml b/changelogs/fragments/7456-add-ssh-control-master.yml deleted file mode 100644 index de6399e2bd..0000000000 --- a/changelogs/fragments/7456-add-ssh-control-master.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ssh_config - adds ``controlmaster``, ``controlpath`` and ``controlpersist`` parameters (https://github.com/ansible-collections/community.general/pull/7456). diff --git a/changelogs/fragments/7461-proxmox-inventory-add-exclude-nodes.yaml b/changelogs/fragments/7461-proxmox-inventory-add-exclude-nodes.yaml deleted file mode 100644 index 40391342f7..0000000000 --- a/changelogs/fragments/7461-proxmox-inventory-add-exclude-nodes.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox inventory plugin - adds an option to exclude nodes from the dynamic inventory generation. The new setting is optional, not using this option will behave as usual (https://github.com/ansible-collections/community.general/issues/6714, https://github.com/ansible-collections/community.general/pull/7461). diff --git a/changelogs/fragments/7462-Add-ostype-parameter-in-LXC-container-clone-of-ProxmoxVE.yaml b/changelogs/fragments/7462-Add-ostype-parameter-in-LXC-container-clone-of-ProxmoxVE.yaml deleted file mode 100644 index 20a9b1d144..0000000000 --- a/changelogs/fragments/7462-Add-ostype-parameter-in-LXC-container-clone-of-ProxmoxVE.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox_ostype - it is now possible to specify the ``ostype`` when creating an LXC container (https://github.com/ansible-collections/community.general/pull/7462). diff --git a/changelogs/fragments/7464-fix-vm-removal-in-proxmox_pool_member.yml b/changelogs/fragments/7464-fix-vm-removal-in-proxmox_pool_member.yml deleted file mode 100644 index b42abc88c0..0000000000 --- a/changelogs/fragments/7464-fix-vm-removal-in-proxmox_pool_member.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - proxmox_pool_member - absent state for type VM did not delete VMs from the pools (https://github.com/ansible-collections/community.general/pull/7464). diff --git a/changelogs/fragments/7465-redfish-firmware-update-message-id-hardening.yml b/changelogs/fragments/7465-redfish-firmware-update-message-id-hardening.yml deleted file mode 100644 index 01a98c2225..0000000000 --- a/changelogs/fragments/7465-redfish-firmware-update-message-id-hardening.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - redfish_command - fix usage of message parsing in ``SimpleUpdate`` and ``MultipartHTTPPushUpdate`` commands to treat the lack of a ``MessageId`` as no message (https://github.com/ansible-collections/community.general/issues/7465, https://github.com/ansible-collections/community.general/pull/7471). diff --git a/changelogs/fragments/7467-fix-gitlab-constants-calls.yml b/changelogs/fragments/7467-fix-gitlab-constants-calls.yml deleted file mode 100644 index 77466f75e6..0000000000 --- a/changelogs/fragments/7467-fix-gitlab-constants-calls.yml +++ /dev/null @@ -1,5 +0,0 @@ -bugfixes: - - gitlab_group_members - fix gitlab constants call in ``gitlab_group_members`` module (https://github.com/ansible-collections/community.general/issues/7467). - - gitlab_project_members - fix gitlab constants call in ``gitlab_project_members`` module (https://github.com/ansible-collections/community.general/issues/7467). - - gitlab_protected_branches - fix gitlab constants call in ``gitlab_protected_branches`` module (https://github.com/ansible-collections/community.general/issues/7467). - - gitlab_user - fix gitlab constants call in ``gitlab_user`` module (https://github.com/ansible-collections/community.general/issues/7467). diff --git a/changelogs/fragments/7472-gitlab-add-ca-path-option.yml b/changelogs/fragments/7472-gitlab-add-ca-path-option.yml deleted file mode 100644 index 48c041ea31..0000000000 --- a/changelogs/fragments/7472-gitlab-add-ca-path-option.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - gitlab modules - add ``ca_path`` option (https://github.com/ansible-collections/community.general/pull/7472). diff --git a/changelogs/fragments/7485-proxmox_vm_info-config.yml b/changelogs/fragments/7485-proxmox_vm_info-config.yml deleted file mode 100644 index ca2fd3dc57..0000000000 --- a/changelogs/fragments/7485-proxmox_vm_info-config.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox_vm_info - add ability to retrieve configuration info (https://github.com/ansible-collections/community.general/pull/7485). diff --git a/changelogs/fragments/7486-gitlab-refactor-package-check.yml b/changelogs/fragments/7486-gitlab-refactor-package-check.yml deleted file mode 100644 index 25b52ac45c..0000000000 --- a/changelogs/fragments/7486-gitlab-refactor-package-check.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - gitlab modules - remove duplicate ``gitlab`` package check (https://github.com/ansible-collections/community.general/pull/7486). diff --git a/changelogs/fragments/7489-netcup-dns-record-types.yml b/changelogs/fragments/7489-netcup-dns-record-types.yml deleted file mode 100644 index b065a4d239..0000000000 --- a/changelogs/fragments/7489-netcup-dns-record-types.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - netcup_dns - adds support for record types ``OPENPGPKEY``, ``SMIMEA``, and ``SSHFP`` (https://github.com/ansible-collections/community.general/pull/7489). \ No newline at end of file diff --git a/changelogs/fragments/7495-proxmox_disk-manipulate-cdrom.yml b/changelogs/fragments/7495-proxmox_disk-manipulate-cdrom.yml deleted file mode 100644 index f3a5b27609..0000000000 --- a/changelogs/fragments/7495-proxmox_disk-manipulate-cdrom.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox_disk - add ability to manipulate CD-ROM drive (https://github.com/ansible-collections/community.general/pull/7495). diff --git a/changelogs/fragments/7499-allow-mtu-setting-on-bond-and-infiniband-interfaces.yml b/changelogs/fragments/7499-allow-mtu-setting-on-bond-and-infiniband-interfaces.yml deleted file mode 100644 index f12aa55760..0000000000 --- a/changelogs/fragments/7499-allow-mtu-setting-on-bond-and-infiniband-interfaces.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - nmcli - allow for the setting of ``MTU`` for ``infiniband`` and ``bond`` interface types (https://github.com/ansible-collections/community.general/pull/7499). diff --git a/changelogs/fragments/7501-type.yml b/changelogs/fragments/7501-type.yml deleted file mode 100644 index 994c31ce5a..0000000000 --- a/changelogs/fragments/7501-type.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "ocapi_utils, oci_utils, redfish_utils module utils - replace ``type()`` calls with ``isinstance()`` calls (https://github.com/ansible-collections/community.general/pull/7501)." diff --git a/changelogs/fragments/7506-pipx-pipargs.yml b/changelogs/fragments/7506-pipx-pipargs.yml deleted file mode 100644 index fb5cb52e6f..0000000000 --- a/changelogs/fragments/7506-pipx-pipargs.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - pipx module utils - change the CLI argument formatter for the ``pip_args`` parameter (https://github.com/ansible-collections/community.general/issues/7497, https://github.com/ansible-collections/community.general/pull/7506). diff --git a/changelogs/fragments/7517-elastic-close-client.yaml b/changelogs/fragments/7517-elastic-close-client.yaml deleted file mode 100644 index ee383d26a6..0000000000 --- a/changelogs/fragments/7517-elastic-close-client.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - elastic callback plugin - close elastic client to not leak resources (https://github.com/ansible-collections/community.general/pull/7517). diff --git a/changelogs/fragments/7535-terraform-fix-multiline-string-handling-in-complex-variables.yml b/changelogs/fragments/7535-terraform-fix-multiline-string-handling-in-complex-variables.yml deleted file mode 100644 index b991522dd6..0000000000 --- a/changelogs/fragments/7535-terraform-fix-multiline-string-handling-in-complex-variables.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "terraform - fix multiline string handling in complex variables (https://github.com/ansible-collections/community.general/pull/7535)." diff --git a/changelogs/fragments/7538-add-krbprincipalattribute-option.yml b/changelogs/fragments/7538-add-krbprincipalattribute-option.yml deleted file mode 100644 index e2e2ce61c2..0000000000 --- a/changelogs/fragments/7538-add-krbprincipalattribute-option.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - keycloak_user_federation - add option for ``krbPrincipalAttribute`` (https://github.com/ansible-collections/community.general/pull/7538). diff --git a/changelogs/fragments/7540-proxmox-update-config.yml b/changelogs/fragments/7540-proxmox-update-config.yml deleted file mode 100644 index d89c26115f..0000000000 --- a/changelogs/fragments/7540-proxmox-update-config.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox - adds ``update`` parameter, allowing update of an already existing containers configuration (https://github.com/ansible-collections/community.general/pull/7540). diff --git a/changelogs/fragments/7542-irc-logentries-ssl.yml b/changelogs/fragments/7542-irc-logentries-ssl.yml deleted file mode 100644 index 6897087dfb..0000000000 --- a/changelogs/fragments/7542-irc-logentries-ssl.yml +++ /dev/null @@ -1,3 +0,0 @@ -bugfixes: - - "log_entries callback plugin - replace ``ssl.wrap_socket`` that was removed from Python 3.12 with code for creating a proper SSL context (https://github.com/ansible-collections/community.general/pull/7542)." - - "irc - replace ``ssl.wrap_socket`` that was removed from Python 3.12 with code for creating a proper SSL context (https://github.com/ansible-collections/community.general/pull/7542)." diff --git a/changelogs/fragments/7550-irc-use_tls-validate_certs.yml b/changelogs/fragments/7550-irc-use_tls-validate_certs.yml deleted file mode 100644 index 0c99d8fd6f..0000000000 --- a/changelogs/fragments/7550-irc-use_tls-validate_certs.yml +++ /dev/null @@ -1,5 +0,0 @@ -minor_changes: - - "irc - add ``validate_certs`` option, and rename ``use_ssl`` to ``use_tls``, while keeping ``use_ssl`` as an alias. - The default value for ``validate_certs`` is ``false`` for backwards compatibility. We recommend to every user of - this module to explicitly set ``use_tls=true`` and `validate_certs=true`` whenever possible, especially when - communicating to IRC servers over the internet (https://github.com/ansible-collections/community.general/pull/7550)." diff --git a/changelogs/fragments/7564-onepassword-lookup-case-insensitive.yaml b/changelogs/fragments/7564-onepassword-lookup-case-insensitive.yaml deleted file mode 100644 index d2eaf2ff11..0000000000 --- a/changelogs/fragments/7564-onepassword-lookup-case-insensitive.yaml +++ /dev/null @@ -1,4 +0,0 @@ -bugfixes: - - >- - onepassword lookup plugin - field and section titles are now case insensitive when using - op CLI version two or later. This matches the behavior of version one (https://github.com/ansible-collections/community.general/pull/7564). diff --git a/changelogs/fragments/7569-infiniband-slave-support.yml b/changelogs/fragments/7569-infiniband-slave-support.yml deleted file mode 100644 index f54460842d..0000000000 --- a/changelogs/fragments/7569-infiniband-slave-support.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - nmcli - allow for ``infiniband`` slaves of ``bond`` interface types (https://github.com/ansible-collections/community.general/pull/7569). diff --git a/changelogs/fragments/7577-fix-apt_rpm-module.yml b/changelogs/fragments/7577-fix-apt_rpm-module.yml deleted file mode 100644 index ef55eb5bd2..0000000000 --- a/changelogs/fragments/7577-fix-apt_rpm-module.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - apt-rpm - the module did not upgrade packages if a newer version exists. Now the package will be reinstalled if the candidate is newer than the installed version (https://github.com/ansible-collections/community.general/issues/7414). diff --git a/changelogs/fragments/7578-irc-tls.yml b/changelogs/fragments/7578-irc-tls.yml deleted file mode 100644 index a7fcbbca29..0000000000 --- a/changelogs/fragments/7578-irc-tls.yml +++ /dev/null @@ -1,4 +0,0 @@ -deprecated_features: - - "irc - the defaults ``false`` for ``use_tls`` and ``validate_certs`` have been deprecated and will change to ``true`` in community.general 10.0.0 - to improve security. You can already improve security now by explicitly setting them to ``true``. Specifying values now disables the deprecation - warning (https://github.com/ansible-collections/community.general/pull/7578)." diff --git a/changelogs/fragments/7588-ipa-config-new-choice-passkey-to-ipauserauthtype.yml b/changelogs/fragments/7588-ipa-config-new-choice-passkey-to-ipauserauthtype.yml deleted file mode 100644 index c9d83c761a..0000000000 --- a/changelogs/fragments/7588-ipa-config-new-choice-passkey-to-ipauserauthtype.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ipa_config - adds ``passkey`` choice to ``ipauserauthtype`` parameter's choices (https://github.com/ansible-collections/community.general/pull/7588). diff --git a/changelogs/fragments/7589-ipa-config-new-choices-idp-and-passkey-to-ipauserauthtype.yml b/changelogs/fragments/7589-ipa-config-new-choices-idp-and-passkey-to-ipauserauthtype.yml deleted file mode 100644 index bf584514ae..0000000000 --- a/changelogs/fragments/7589-ipa-config-new-choices-idp-and-passkey-to-ipauserauthtype.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ipa_user - adds ``idp`` and ``passkey`` choice to ``ipauserauthtype`` parameter's choices (https://github.com/ansible-collections/community.general/pull/7589). diff --git a/changelogs/fragments/7600-proxmox_kvm-hookscript.yml b/changelogs/fragments/7600-proxmox_kvm-hookscript.yml deleted file mode 100644 index 5d79e71657..0000000000 --- a/changelogs/fragments/7600-proxmox_kvm-hookscript.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "proxmox_kvm - support the ``hookscript`` parameter (https://github.com/ansible-collections/community.general/issues/7600)." diff --git a/changelogs/fragments/7601-lvol-fix.yml b/changelogs/fragments/7601-lvol-fix.yml deleted file mode 100644 index b83fe15683..0000000000 --- a/changelogs/fragments/7601-lvol-fix.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - lvol - test for output messages in both ``stdout`` and ``stderr`` (https://github.com/ansible-collections/community.general/pull/7601, https://github.com/ansible-collections/community.general/issues/7182). diff --git a/changelogs/fragments/7612-interface_file-method.yml b/changelogs/fragments/7612-interface_file-method.yml deleted file mode 100644 index 38fcb71503..0000000000 --- a/changelogs/fragments/7612-interface_file-method.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "interface_files - also consider ``address_family`` when changing ``option=method`` (https://github.com/ansible-collections/community.general/issues/7610, https://github.com/ansible-collections/community.general/pull/7612)." diff --git a/changelogs/fragments/7626-redfish-info-add-boot-progress-property.yml b/changelogs/fragments/7626-redfish-info-add-boot-progress-property.yml deleted file mode 100644 index 919383686b..0000000000 --- a/changelogs/fragments/7626-redfish-info-add-boot-progress-property.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - redfish_info - adding the ``BootProgress`` property when getting ``Systems`` info (https://github.com/ansible-collections/community.general/pull/7626). diff --git a/changelogs/fragments/7641-fix-keycloak-api-client-to-quote-properly.yml b/changelogs/fragments/7641-fix-keycloak-api-client-to-quote-properly.yml deleted file mode 100644 index c11cbf3b06..0000000000 --- a/changelogs/fragments/7641-fix-keycloak-api-client-to-quote-properly.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - keycloak_* - fix Keycloak API client to quote ``/`` properly (https://github.com/ansible-collections/community.general/pull/7641). diff --git a/changelogs/fragments/7645-Keycloak-print-error-msg-from-server.yml b/changelogs/fragments/7645-Keycloak-print-error-msg-from-server.yml deleted file mode 100644 index 509ab0fd81..0000000000 --- a/changelogs/fragments/7645-Keycloak-print-error-msg-from-server.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - keycloak module utils - expose error message from Keycloak server for HTTP errors in some specific situations (https://github.com/ansible-collections/community.general/pull/7645). \ No newline at end of file diff --git a/changelogs/fragments/7646-fix-order-number-detection-in-dn.yml b/changelogs/fragments/7646-fix-order-number-detection-in-dn.yml deleted file mode 100644 index f2d2379872..0000000000 --- a/changelogs/fragments/7646-fix-order-number-detection-in-dn.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - ldap - previously the order number (if present) was expected to follow an equals sign in the DN. This makes it so the order number string is identified correctly anywhere within the DN (https://github.com/ansible-collections/community.general/issues/7646). diff --git a/changelogs/fragments/7653-fix-cloudflare-lookup.yml b/changelogs/fragments/7653-fix-cloudflare-lookup.yml deleted file mode 100644 index f370a1c1d1..0000000000 --- a/changelogs/fragments/7653-fix-cloudflare-lookup.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - cloudflare_dns - fix Cloudflare lookup of SHFP records (https://github.com/ansible-collections/community.general/issues/7652). diff --git a/changelogs/fragments/7676-lvol-pvs-as-list.yml b/changelogs/fragments/7676-lvol-pvs-as-list.yml deleted file mode 100644 index aa28fff59d..0000000000 --- a/changelogs/fragments/7676-lvol-pvs-as-list.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - lvol - change ``pvs`` argument type to list of strings (https://github.com/ansible-collections/community.general/pull/7676, https://github.com/ansible-collections/community.general/issues/7504). diff --git a/changelogs/fragments/7696-avoid-attempt-to-delete-non-existing-user.yml b/changelogs/fragments/7696-avoid-attempt-to-delete-non-existing-user.yml deleted file mode 100644 index db57d68233..0000000000 --- a/changelogs/fragments/7696-avoid-attempt-to-delete-non-existing-user.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - keycloak_user - when ``force`` is set, but user does not exist, do not try to delete it (https://github.com/ansible-collections/community.general/pull/7696). diff --git a/changelogs/fragments/7698-improvements-to-keycloak_realm_key.yml b/changelogs/fragments/7698-improvements-to-keycloak_realm_key.yml deleted file mode 100644 index 0cd996c510..0000000000 --- a/changelogs/fragments/7698-improvements-to-keycloak_realm_key.yml +++ /dev/null @@ -1,4 +0,0 @@ -minor_changes: - - keycloak_realm_key - the ``provider_id`` option now supports RSA encryption key usage (value ``rsa-enc``) (https://github.com/ansible-collections/community.general/pull/7698). - - keycloak_realm_key - the ``config.algorithm`` option now supports 8 additional key algorithms (https://github.com/ansible-collections/community.general/pull/7698). - - keycloak_realm_key - the ``config.certificate`` option value is no longer defined with ``no_log=True`` (https://github.com/ansible-collections/community.general/pull/7698). \ No newline at end of file diff --git a/changelogs/fragments/7703-ssh_config_add_keys_to_agent_option.yml b/changelogs/fragments/7703-ssh_config_add_keys_to_agent_option.yml deleted file mode 100644 index 99893a0ff3..0000000000 --- a/changelogs/fragments/7703-ssh_config_add_keys_to_agent_option.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ssh_config - new feature to set ``AddKeysToAgent`` option to ``yes`` or ``no`` (https://github.com/ansible-collections/community.general/pull/7703). diff --git a/changelogs/fragments/7704-ssh_config_identities_only_option.yml b/changelogs/fragments/7704-ssh_config_identities_only_option.yml deleted file mode 100644 index 9efa10b70f..0000000000 --- a/changelogs/fragments/7704-ssh_config_identities_only_option.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ssh_config - new feature to set ``IdentitiesOnly`` option to ``yes`` or ``no`` (https://github.com/ansible-collections/community.general/pull/7704). diff --git a/changelogs/fragments/7717-prevent-modprobe-error.yml b/changelogs/fragments/7717-prevent-modprobe-error.yml deleted file mode 100644 index bfef30e67b..0000000000 --- a/changelogs/fragments/7717-prevent-modprobe-error.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - modprobe - listing modules files or modprobe files could trigger a FileNotFoundError if ``/etc/modprobe.d`` or ``/etc/modules-load.d`` did not exist. Relevant functions now return empty lists if the directories do not exist to avoid crashing the module (https://github.com/ansible-collections/community.general/issues/7717). diff --git a/changelogs/fragments/7723-ipa-pwpolicy-update-pwpolicy-module.yml b/changelogs/fragments/7723-ipa-pwpolicy-update-pwpolicy-module.yml deleted file mode 100644 index bffd40efcd..0000000000 --- a/changelogs/fragments/7723-ipa-pwpolicy-update-pwpolicy-module.yml +++ /dev/null @@ -1,3 +0,0 @@ -minor_changes: - - ipa_pwpolicy - update module to support ``maxrepeat``, ``maxsequence``, ``dictcheck``, ``usercheck``, ``gracelimit`` parameters in FreeIPA password policies (https://github.com/ansible-collections/community.general/pull/7723). - - ipa_pwpolicy - refactor module and exchange a sequence ``if`` statements with a ``for`` loop (https://github.com/ansible-collections/community.general/pull/7723). diff --git a/changelogs/fragments/7737-add-ipa-dnsrecord-ns-type.yml b/changelogs/fragments/7737-add-ipa-dnsrecord-ns-type.yml deleted file mode 100644 index 534d96e123..0000000000 --- a/changelogs/fragments/7737-add-ipa-dnsrecord-ns-type.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ipa_dnsrecord - adds ability to manage NS record types (https://github.com/ansible-collections/community.general/pull/7737). diff --git a/changelogs/fragments/7740-add-message-id-header-to-mail-module.yml b/changelogs/fragments/7740-add-message-id-header-to-mail-module.yml deleted file mode 100644 index 1c142b62ef..0000000000 --- a/changelogs/fragments/7740-add-message-id-header-to-mail-module.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - mail - add ``Message-ID`` header; which is required by some mail servers (https://github.com/ansible-collections/community.general/pull/7740). diff --git a/changelogs/fragments/7746-raw_post-without-actions.yml b/changelogs/fragments/7746-raw_post-without-actions.yml deleted file mode 100644 index 10dc110c5e..0000000000 --- a/changelogs/fragments/7746-raw_post-without-actions.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - xcc_redfish_command - added support for raw POSTs (``command=PostResource`` in ``category=Raw``) without a specific action info (https://github.com/ansible-collections/community.general/pull/7746). diff --git a/changelogs/fragments/7754-fixed-payload-format.yml b/changelogs/fragments/7754-fixed-payload-format.yml deleted file mode 100644 index 01458053e5..0000000000 --- a/changelogs/fragments/7754-fixed-payload-format.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - statusio_maintenance - fix error caused by incorrectly formed API data payload. Was raising "Failed to create maintenance HTTP Error 400 Bad Request" caused by bad data type for date/time and deprecated dict keys (https://github.com/ansible-collections/community.general/pull/7754). \ No newline at end of file diff --git a/changelogs/fragments/7765-mail-message-id.yml b/changelogs/fragments/7765-mail-message-id.yml deleted file mode 100644 index 54af767ecf..0000000000 --- a/changelogs/fragments/7765-mail-message-id.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "mail module, mail callback plugin - allow to configure the domain name of the Message-ID header with a new ``message_id_domain`` option (https://github.com/ansible-collections/community.general/pull/7765)." diff --git a/changelogs/fragments/7782-cloudflare_dns-spf.yml b/changelogs/fragments/7782-cloudflare_dns-spf.yml deleted file mode 100644 index 83e7fe79bb..0000000000 --- a/changelogs/fragments/7782-cloudflare_dns-spf.yml +++ /dev/null @@ -1,2 +0,0 @@ -removed_features: - - "cloudflare_dns - remove support for SPF records. These are no longer supported by CloudFlare (https://github.com/ansible-collections/community.general/pull/7782)." diff --git a/changelogs/fragments/7789-keycloak-user-federation-custom-provider-type.yml b/changelogs/fragments/7789-keycloak-user-federation-custom-provider-type.yml deleted file mode 100644 index dd20a4ea18..0000000000 --- a/changelogs/fragments/7789-keycloak-user-federation-custom-provider-type.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - keycloak_user_federation - allow custom user storage providers to be set through ``provider_id`` (https://github.com/ansible-collections/community.general/pull/7789). diff --git a/changelogs/fragments/7790-gitlab-runner-api-pagination.yml b/changelogs/fragments/7790-gitlab-runner-api-pagination.yml deleted file mode 100644 index 59a65ea8ef..0000000000 --- a/changelogs/fragments/7790-gitlab-runner-api-pagination.yml +++ /dev/null @@ -1,8 +0,0 @@ -bugfixes: - - gitlab_runner - fix pagination when checking for existing runners (https://github.com/ansible-collections/community.general/pull/7790). - -minor_changes: - - gitlab_deploy_key, gitlab_group_members, gitlab_group_variable, gitlab_hook, - gitlab_instance_variable, gitlab_project_badge, gitlab_project_variable, - gitlab_user - improve API pagination and compatibility with different versions - of ``python-gitlab`` (https://github.com/ansible-collections/community.general/pull/7790). diff --git a/changelogs/fragments/7791-proxmox_kvm-state-template-will-check-status-first.yaml b/changelogs/fragments/7791-proxmox_kvm-state-template-will-check-status-first.yaml deleted file mode 100644 index 1e061ce6af..0000000000 --- a/changelogs/fragments/7791-proxmox_kvm-state-template-will-check-status-first.yaml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - proxmox_kvm - running ``state=template`` will first check whether VM is already a template (https://github.com/ansible-collections/community.general/pull/7792). diff --git a/changelogs/fragments/7797-ipa-fix-otp-idempotency.yml b/changelogs/fragments/7797-ipa-fix-otp-idempotency.yml deleted file mode 100644 index 43fd4f5251..0000000000 --- a/changelogs/fragments/7797-ipa-fix-otp-idempotency.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - ipa_otptoken - the module expect ``ipatokendisabled`` as string but the ``ipatokendisabled`` value is returned as a boolean (https://github.com/ansible-collections/community.general/pull/7795). diff --git a/changelogs/fragments/7821-mssql_script-py2.yml b/changelogs/fragments/7821-mssql_script-py2.yml deleted file mode 100644 index 79de688628..0000000000 --- a/changelogs/fragments/7821-mssql_script-py2.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "mssql_script - make the module work with Python 2 (https://github.com/ansible-collections/community.general/issues/7818, https://github.com/ansible-collections/community.general/pull/7821)." diff --git a/changelogs/fragments/7826-consul-modules-refactoring.yaml b/changelogs/fragments/7826-consul-modules-refactoring.yaml deleted file mode 100644 index a51352d88e..0000000000 --- a/changelogs/fragments/7826-consul-modules-refactoring.yaml +++ /dev/null @@ -1,7 +0,0 @@ -minor_changes: - - 'consul_policy, consul_role, consul_session - removed dependency on ``requests`` and factored out common parts (https://github.com/ansible-collections/community.general/pull/7826, https://github.com/ansible-collections/community.general/pull/7878).' - - consul_policy - added support for diff and check mode (https://github.com/ansible-collections/community.general/pull/7878). - - consul_role - added support for diff mode (https://github.com/ansible-collections/community.general/pull/7878). - - consul_role - added support for templated policies (https://github.com/ansible-collections/community.general/pull/7878). - - consul_role - ``service_identities`` now expects a ``service_name`` option to match the Consul API, the old ``name`` is still supported as alias (https://github.com/ansible-collections/community.general/pull/7878). - - consul_role - ``node_identities`` now expects a ``node_name`` option to match the Consul API, the old ``name`` is still supported as alias (https://github.com/ansible-collections/community.general/pull/7878). \ No newline at end of file diff --git a/changelogs/fragments/7843-proxmox_kvm-update_unsafe.yml b/changelogs/fragments/7843-proxmox_kvm-update_unsafe.yml deleted file mode 100644 index dcb1ebb218..0000000000 --- a/changelogs/fragments/7843-proxmox_kvm-update_unsafe.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox_kvm - add parameter ``update_unsafe`` to avoid limitations when updating dangerous values (https://github.com/ansible-collections/community.general/pull/7843). diff --git a/changelogs/fragments/7847-gitlab-issue-title.yml b/changelogs/fragments/7847-gitlab-issue-title.yml deleted file mode 100644 index c8b8e49905..0000000000 --- a/changelogs/fragments/7847-gitlab-issue-title.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - gitlab_issue - fix behavior to search GitLab issue, using ``search`` keyword instead of ``title`` (https://github.com/ansible-collections/community.general/issues/7846). diff --git a/changelogs/fragments/7870-homebrew-cask-installed-detection.yml b/changelogs/fragments/7870-homebrew-cask-installed-detection.yml deleted file mode 100644 index 1c70c9a2d4..0000000000 --- a/changelogs/fragments/7870-homebrew-cask-installed-detection.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - homebrew - detect already installed formulae and casks using JSON output from ``brew info`` (https://github.com/ansible-collections/community.general/issues/864). diff --git a/changelogs/fragments/7872-proxmox_fix-update-if-setting-doesnt-exist.yaml b/changelogs/fragments/7872-proxmox_fix-update-if-setting-doesnt-exist.yaml deleted file mode 100644 index 82b4fe31d9..0000000000 --- a/changelogs/fragments/7872-proxmox_fix-update-if-setting-doesnt-exist.yaml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - proxmox - fix updating a container config if the setting does not already exist (https://github.com/ansible-collections/community.general/pull/7872). diff --git a/changelogs/fragments/7874-incus_connection_treats_inventory_hostname_as_literal_in_remotes.yml b/changelogs/fragments/7874-incus_connection_treats_inventory_hostname_as_literal_in_remotes.yml deleted file mode 100644 index 83d302e9b9..0000000000 --- a/changelogs/fragments/7874-incus_connection_treats_inventory_hostname_as_literal_in_remotes.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "incus connection plugin - treats ``inventory_hostname`` as a variable instead of a literal in remote connections (https://github.com/ansible-collections/community.general/issues/7874)." diff --git a/changelogs/fragments/7880-ipa-fix-sudo-and-hbcalrule-idempotence.yml b/changelogs/fragments/7880-ipa-fix-sudo-and-hbcalrule-idempotence.yml deleted file mode 100644 index cb2caa3780..0000000000 --- a/changelogs/fragments/7880-ipa-fix-sudo-and-hbcalrule-idempotence.yml +++ /dev/null @@ -1,3 +0,0 @@ -bugfixes: - - ipa_sudorule - the module uses a string for ``ipaenabledflag`` for new FreeIPA versions while the returned value is a boolean (https://github.com/ansible-collections/community.general/pull/7880). - - ipa_hbacrule - the module uses a string for ``ipaenabledflag`` for new FreeIPA versions while the returned value is a boolean (https://github.com/ansible-collections/community.general/pull/7880). diff --git a/changelogs/fragments/7881-fix-keycloak-client-ckeckmode.yml b/changelogs/fragments/7881-fix-keycloak-client-ckeckmode.yml deleted file mode 100644 index 485950c11c..0000000000 --- a/changelogs/fragments/7881-fix-keycloak-client-ckeckmode.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - keycloak_client - fixes issue when metadata is provided in desired state when task is in check mode (https://github.com/ansible-collections/community.general/issues/1226, https://github.com/ansible-collections/community.general/pull/7881). \ No newline at end of file diff --git a/changelogs/fragments/7882-add-redfish-get-service-identification.yml b/changelogs/fragments/7882-add-redfish-get-service-identification.yml deleted file mode 100644 index 463c9a2bc5..0000000000 --- a/changelogs/fragments/7882-add-redfish-get-service-identification.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - redfish_info - add command ``GetServiceIdentification`` to get service identification (https://github.com/ansible-collections/community.general/issues/7882). diff --git a/changelogs/fragments/7896-add-terraform-diff-mode.yml b/changelogs/fragments/7896-add-terraform-diff-mode.yml deleted file mode 100644 index 7c0834efa5..0000000000 --- a/changelogs/fragments/7896-add-terraform-diff-mode.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - terraform - add support for ``diff_mode`` for terraform resource_changes (https://github.com/ansible-collections/community.general/pull/7896). diff --git a/changelogs/fragments/7897-consul-action-group.yaml b/changelogs/fragments/7897-consul-action-group.yaml deleted file mode 100644 index 1764e1970d..0000000000 --- a/changelogs/fragments/7897-consul-action-group.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - consul_auth_method, consul_binding_rule, consul_policy, consul_role, consul_session, consul_token - added action group ``community.general.consul`` (https://github.com/ansible-collections/community.general/pull/7897). diff --git a/changelogs/fragments/7901-consul-acl-deprecation.yaml b/changelogs/fragments/7901-consul-acl-deprecation.yaml deleted file mode 100644 index 9480b04ce9..0000000000 --- a/changelogs/fragments/7901-consul-acl-deprecation.yaml +++ /dev/null @@ -1,3 +0,0 @@ -deprecated_features: - - "consul_acl - the module has been deprecated and will be removed in community.general 10.0.0. ``consul_token`` and ``consul_policy`` - can be used instead (https://github.com/ansible-collections/community.general/pull/7901)." \ No newline at end of file diff --git a/changelogs/fragments/7916-add-redfish-set-service-identification.yml b/changelogs/fragments/7916-add-redfish-set-service-identification.yml deleted file mode 100644 index 2b1f2ca7b3..0000000000 --- a/changelogs/fragments/7916-add-redfish-set-service-identification.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - redfish_config - add command ``SetServiceIdentification`` to set service identification (https://github.com/ansible-collections/community.general/issues/7916). diff --git a/changelogs/fragments/7919-onepassword-fieldname-casing.yaml b/changelogs/fragments/7919-onepassword-fieldname-casing.yaml deleted file mode 100644 index 9119f896f0..0000000000 --- a/changelogs/fragments/7919-onepassword-fieldname-casing.yaml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - onepassword lookup plugin - failed for fields that were in sections and had uppercase letters in the label/ID. Field lookups are now case insensitive in all cases (https://github.com/ansible-collections/community.general/pull/7919). diff --git a/changelogs/fragments/7951-fix-redfish_info-exception.yml b/changelogs/fragments/7951-fix-redfish_info-exception.yml deleted file mode 100644 index cd5707da4b..0000000000 --- a/changelogs/fragments/7951-fix-redfish_info-exception.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "redfish_info - correct uncaught exception when attempting to retrieve ``Chassis`` information (https://github.com/ansible-collections/community.general/pull/7952)." diff --git a/changelogs/fragments/7953-proxmox_kvm-fix_status_check.yml b/changelogs/fragments/7953-proxmox_kvm-fix_status_check.yml deleted file mode 100644 index 10f8e6d26a..0000000000 --- a/changelogs/fragments/7953-proxmox_kvm-fix_status_check.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - proxmox_kvm - fixed status check getting from node-specific API endpoint (https://github.com/ansible-collections/community.general/issues/7817). diff --git a/changelogs/fragments/7956-adding-releases_events-option-to-gitlab_hook-module.yaml b/changelogs/fragments/7956-adding-releases_events-option-to-gitlab_hook-module.yaml deleted file mode 100644 index 30186804d4..0000000000 --- a/changelogs/fragments/7956-adding-releases_events-option-to-gitlab_hook-module.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - gitlab_hook - adds ``releases_events`` parameter for supporting Releases events triggers on GitLab hooks (https://github.com/ansible-collections/community.general/pull/7956). \ No newline at end of file diff --git a/changelogs/fragments/7963-fix-terraform-diff-absent.yml b/changelogs/fragments/7963-fix-terraform-diff-absent.yml deleted file mode 100644 index 4e2cf53c9b..0000000000 --- a/changelogs/fragments/7963-fix-terraform-diff-absent.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - terraform - fix ``diff_mode`` in state ``absent`` and when terraform ``resource_changes`` does not exist (https://github.com/ansible-collections/community.general/pull/7963). diff --git a/changelogs/fragments/7970-fix-cargo-path-idempotency.yaml b/changelogs/fragments/7970-fix-cargo-path-idempotency.yaml deleted file mode 100644 index 143247bc91..0000000000 --- a/changelogs/fragments/7970-fix-cargo-path-idempotency.yaml +++ /dev/null @@ -1,10 +0,0 @@ -bugfixes: - - "cargo - fix idempotency issues when using a custom installation path - for packages (using the ``--path`` parameter). - The initial installation runs fine, but subsequent runs use the - ``get_installed()`` function which did not check the given installation - location, before running ``cargo install``. This resulted in a false - ``changed`` state. - Also the removal of packeges using ``state: absent`` failed, as the - installation check did not use the given parameter - (https://github.com/ansible-collections/community.general/pull/7970)." diff --git a/changelogs/fragments/7976-add-mssql_script-transactional-support.yml b/changelogs/fragments/7976-add-mssql_script-transactional-support.yml deleted file mode 100644 index dc6f335247..0000000000 --- a/changelogs/fragments/7976-add-mssql_script-transactional-support.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - mssql_script - adds transactional (rollback/commit) support via optional boolean param ``transaction`` (https://github.com/ansible-collections/community.general/pull/7976). diff --git a/changelogs/fragments/7983-sudoers-add-support-noexec.yml b/changelogs/fragments/7983-sudoers-add-support-noexec.yml deleted file mode 100644 index f58e6f7ec8..0000000000 --- a/changelogs/fragments/7983-sudoers-add-support-noexec.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - sudoers - add support for the ``NOEXEC`` tag in sudoers rules (https://github.com/ansible-collections/community.general/pull/7983). diff --git a/changelogs/fragments/7994-bitwarden-session-arg.yaml b/changelogs/fragments/7994-bitwarden-session-arg.yaml deleted file mode 100644 index 36f9622ac0..0000000000 --- a/changelogs/fragments/7994-bitwarden-session-arg.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "bitwarden lookup plugin - add ``bw_session`` option, to pass session key instead of reading from env (https://github.com/ansible-collections/community.general/pull/7994)." diff --git a/changelogs/fragments/7996-add-templating-support-to-icinga2-inventory.yml b/changelogs/fragments/7996-add-templating-support-to-icinga2-inventory.yml deleted file mode 100644 index 9998583b83..0000000000 --- a/changelogs/fragments/7996-add-templating-support-to-icinga2-inventory.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - icinga2 inventory plugin - add Jinja2 templating support to ``url``, ``user``, and ``password`` paramenters (https://github.com/ansible-collections/community.general/issues/7074, https://github.com/ansible-collections/community.general/pull/7996). \ No newline at end of file diff --git a/changelogs/fragments/7998-icinga2-inventory-group_by_hostgroups-parameter.yml b/changelogs/fragments/7998-icinga2-inventory-group_by_hostgroups-parameter.yml deleted file mode 100644 index 1170a108fd..0000000000 --- a/changelogs/fragments/7998-icinga2-inventory-group_by_hostgroups-parameter.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - icinga2 inventory plugin - adds new parameter ``group_by_hostgroups`` in order to make grouping by Icinga2 hostgroups optional (https://github.com/ansible-collections/community.general/pull/7998). \ No newline at end of file diff --git a/changelogs/fragments/8003-redfish-get-update-status-empty-response.yml b/changelogs/fragments/8003-redfish-get-update-status-empty-response.yml deleted file mode 100644 index 21796e7a0e..0000000000 --- a/changelogs/fragments/8003-redfish-get-update-status-empty-response.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - redfish_info - allow for a GET operation invoked by ``GetUpdateStatus`` to allow for an empty response body for cases where a service returns 204 No Content (https://github.com/ansible-collections/community.general/issues/8003). diff --git a/changelogs/fragments/8013-bitwarden-full-collection-item-list.yaml b/changelogs/fragments/8013-bitwarden-full-collection-item-list.yaml deleted file mode 100644 index 7337233aea..0000000000 --- a/changelogs/fragments/8013-bitwarden-full-collection-item-list.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "bitwarden lookup plugin - allows to fetch all records of a given collection ID, by allowing to pass an empty value for ``search_value`` when ``collection_id`` is provided (https://github.com/ansible-collections/community.general/pull/8013)." diff --git a/changelogs/fragments/8029-iptables-state-restore-check-mode.yml b/changelogs/fragments/8029-iptables-state-restore-check-mode.yml deleted file mode 100644 index 900ea50988..0000000000 --- a/changelogs/fragments/8029-iptables-state-restore-check-mode.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - iptables_state - fix idempotency issues when restoring incomplete iptables dumps (https://github.com/ansible-collections/community.general/issues/8029). diff --git a/changelogs/fragments/8038-proxmox-startup.yml b/changelogs/fragments/8038-proxmox-startup.yml deleted file mode 100644 index f8afbc0c4e..0000000000 --- a/changelogs/fragments/8038-proxmox-startup.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - proxmox - adds ``startup`` parameters to configure startup order, startup delay and shutdown delay (https://github.com/ansible-collections/community.general/pull/8038). diff --git a/changelogs/fragments/8048-fix-homebrew-module-error-reporting-on-become-true.yaml b/changelogs/fragments/8048-fix-homebrew-module-error-reporting-on-become-true.yaml deleted file mode 100644 index 9954be302a..0000000000 --- a/changelogs/fragments/8048-fix-homebrew-module-error-reporting-on-become-true.yaml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - homebrew - error returned from brew command was ignored and tried to parse empty JSON. Fix now checks for an error and raises it to give accurate error message to users (https://github.com/ansible-collections/community.general/issues/8047). diff --git a/changelogs/fragments/8051-Redfish-Wait-For-Service.yml b/changelogs/fragments/8051-Redfish-Wait-For-Service.yml new file mode 100644 index 0000000000..826c40e8af --- /dev/null +++ b/changelogs/fragments/8051-Redfish-Wait-For-Service.yml @@ -0,0 +1,3 @@ +minor_changes: + - redfish_info - add command ``CheckAvailability`` to check if a service is accessible (https://github.com/ansible-collections/community.general/issues/8051, https://github.com/ansible-collections/community.general/pull/8434). + - redfish_command - add ``wait`` and ``wait_timeout`` options to allow a user to block a command until a service is accessible after performing the requested command (https://github.com/ansible-collections/community.general/issues/8051, https://github.com/ansible-collections/community.general/pull/8434). diff --git a/changelogs/fragments/8057-pam_limits-check-mode.yml b/changelogs/fragments/8057-pam_limits-check-mode.yml deleted file mode 100644 index f6f034e9b8..0000000000 --- a/changelogs/fragments/8057-pam_limits-check-mode.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "pam_limits - when the file does not exist, do not create it in check mode (https://github.com/ansible-collections/community.general/issues/8050, https://github.com/ansible-collections/community.general/pull/8057)." diff --git a/changelogs/fragments/8073-ldap-attrs-diff.yml b/changelogs/fragments/8073-ldap-attrs-diff.yml deleted file mode 100644 index 071fc2919e..0000000000 --- a/changelogs/fragments/8073-ldap-attrs-diff.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ldap_attrs - module now supports diff mode, showing which attributes are changed within an operation (https://github.com/ansible-collections/community.general/pull/8073). \ No newline at end of file diff --git a/changelogs/fragments/8075-optional-space-around-section-names.yaml b/changelogs/fragments/8075-optional-space-around-section-names.yaml deleted file mode 100644 index 2e44555f08..0000000000 --- a/changelogs/fragments/8075-optional-space-around-section-names.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "ini_file - support optional spaces between section names and their surrounding brackets (https://github.com/ansible-collections/community.general/pull/8075)." diff --git a/changelogs/fragments/8087-removed-redundant-unicode-prefixes.yml b/changelogs/fragments/8087-removed-redundant-unicode-prefixes.yml deleted file mode 100644 index 1224ebdfa2..0000000000 --- a/changelogs/fragments/8087-removed-redundant-unicode-prefixes.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "revbitspss lookup plugin - removed a redundant unicode prefix. The prefix was not necessary for Python 3 and has been cleaned up to streamline the code (https://github.com/ansible-collections/community.general/pull/8087)." diff --git a/changelogs/fragments/8091-consul-token-fixes.yaml b/changelogs/fragments/8091-consul-token-fixes.yaml deleted file mode 100644 index c734623588..0000000000 --- a/changelogs/fragments/8091-consul-token-fixes.yaml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "consul_token - fix token creation without ``accessor_id`` (https://github.com/ansible-collections/community.general/pull/8091)." \ No newline at end of file diff --git a/changelogs/fragments/8100-haproxy-drain-fails-on-down-backend.yml b/changelogs/fragments/8100-haproxy-drain-fails-on-down-backend.yml deleted file mode 100644 index 58f1478914..0000000000 --- a/changelogs/fragments/8100-haproxy-drain-fails-on-down-backend.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "haproxy - fix an issue where HAProxy could get stuck in DRAIN mode when the backend was unreachable (https://github.com/ansible-collections/community.general/issues/8092)." diff --git a/changelogs/fragments/8116-java_cert-enable-owner-group-mode-args.yml b/changelogs/fragments/8116-java_cert-enable-owner-group-mode-args.yml deleted file mode 100644 index f36c145d74..0000000000 --- a/changelogs/fragments/8116-java_cert-enable-owner-group-mode-args.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - java_cert - enable ``owner``, ``group``, ``mode``, and other generic file arguments (https://github.com/ansible-collections/community.general/pull/8116). \ No newline at end of file diff --git a/changelogs/fragments/8118-fix-bond-slave-honoring-mtu.yml b/changelogs/fragments/8118-fix-bond-slave-honoring-mtu.yml deleted file mode 100644 index 47f8af9ac3..0000000000 --- a/changelogs/fragments/8118-fix-bond-slave-honoring-mtu.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - nmcli - allow setting ``MTU`` for ``bond-slave`` interface types (https://github.com/ansible-collections/community.general/pull/8118). diff --git a/changelogs/fragments/8133-add-error-message-for-linode-inventory-plugin.yaml b/changelogs/fragments/8133-add-error-message-for-linode-inventory-plugin.yaml deleted file mode 100644 index 755d7ed4fe..0000000000 --- a/changelogs/fragments/8133-add-error-message-for-linode-inventory-plugin.yaml +++ /dev/null @@ -1,3 +0,0 @@ -bugfixes: - - linode inventory plugin - add descriptive error message for linode inventory plugin (https://github.com/ansible-collections/community.general/pull/8133). - diff --git a/changelogs/fragments/8158-gitlab-version-check.yml b/changelogs/fragments/8158-gitlab-version-check.yml deleted file mode 100644 index 046bca938f..0000000000 --- a/changelogs/fragments/8158-gitlab-version-check.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "gitlab_issue, gitlab_label, gitlab_milestone - avoid crash during version comparison when the python-gitlab Python module is not installed (https://github.com/ansible-collections/community.general/pull/8158)." diff --git a/changelogs/fragments/8169-lxml.yml b/changelogs/fragments/8169-lxml.yml deleted file mode 100644 index e2c1b8b952..0000000000 --- a/changelogs/fragments/8169-lxml.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - "xml - make module work with lxml 5.1.1, which removed some internals that the module was relying on (https://github.com/ansible-collections/community.general/pull/8169)." diff --git a/changelogs/fragments/8214-sudosu-not-working-on-some-BSD-machines.yml b/changelogs/fragments/8214-sudosu-not-working-on-some-BSD-machines.yml new file mode 100644 index 0000000000..411ba8e868 --- /dev/null +++ b/changelogs/fragments/8214-sudosu-not-working-on-some-BSD-machines.yml @@ -0,0 +1,2 @@ +minor_changes: + - sudosu become plugin - added an option (``alt_method``) to enhance compatibility with more versions of ``su`` (https://github.com/ansible-collections/community.general/pull/8214). diff --git a/changelogs/fragments/8402-add-diif-mode-openbsd-pkg.yml b/changelogs/fragments/8402-add-diif-mode-openbsd-pkg.yml new file mode 100644 index 0000000000..2a4e7dfd8d --- /dev/null +++ b/changelogs/fragments/8402-add-diif-mode-openbsd-pkg.yml @@ -0,0 +1,2 @@ +minor_changes: + - openbsd_pkg - adds diff support to show changes in installed package list. This does not yet work for check mode (https://github.com/ansible-collections/community.general/pull/8402). diff --git a/changelogs/fragments/8403-fix-typeerror-in-keycloak-client.yaml b/changelogs/fragments/8403-fix-typeerror-in-keycloak-client.yaml new file mode 100644 index 0000000000..b8acf7b09b --- /dev/null +++ b/changelogs/fragments/8403-fix-typeerror-in-keycloak-client.yaml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_client - fix TypeError when sanitizing the ``saml.signing.private.key`` attribute in the module's diff or state output. The ``sanitize_cr`` function expected a dict where in some cases a list might occur (https://github.com/ansible-collections/community.general/pull/8403). diff --git a/changelogs/fragments/8404-ipa_dnsrecord_sshfp.yml b/changelogs/fragments/8404-ipa_dnsrecord_sshfp.yml new file mode 100644 index 0000000000..e989f5dbb1 --- /dev/null +++ b/changelogs/fragments/8404-ipa_dnsrecord_sshfp.yml @@ -0,0 +1,2 @@ +minor_changes: + - ipa_dnsrecord - adds ``SSHFP`` record type for managing SSH fingerprints in FreeIPA DNS (https://github.com/ansible-collections/community.general/pull/8404). diff --git a/changelogs/fragments/8405-gitlab-remove-basic-auth.yml b/changelogs/fragments/8405-gitlab-remove-basic-auth.yml new file mode 100644 index 0000000000..f8a03a3d71 --- /dev/null +++ b/changelogs/fragments/8405-gitlab-remove-basic-auth.yml @@ -0,0 +1,2 @@ +removed_features: + - gitlab modules - remove basic auth feature (https://github.com/ansible-collections/community.general/pull/8405). diff --git a/changelogs/fragments/8406-fix-homebrew-cask-warning.yaml b/changelogs/fragments/8406-fix-homebrew-cask-warning.yaml new file mode 100644 index 0000000000..0e3bf38ed3 --- /dev/null +++ b/changelogs/fragments/8406-fix-homebrew-cask-warning.yaml @@ -0,0 +1,2 @@ +bugfixes: + - homebrew - do not fail when brew prints warnings (https://github.com/ansible-collections/community.general/pull/8406, https://github.com/ansible-collections/community.general/issues/7044). diff --git a/changelogs/fragments/8411-locale-gen-vardict.yml b/changelogs/fragments/8411-locale-gen-vardict.yml new file mode 100644 index 0000000000..5220731281 --- /dev/null +++ b/changelogs/fragments/8411-locale-gen-vardict.yml @@ -0,0 +1,11 @@ +bugfixes: + - django module utils - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - cpanm - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - gconftool2_info - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - hponcfg - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - kernel_blacklist - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - locale_gen - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - mksysb - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - pipx_info - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - snap - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). + - snap_alias - use new ``VarDict`` to prevent deprecation warning (https://github.com/ansible-collections/community.general/issues/8410, https://github.com/ansible-collections/community.general/pull/8411). diff --git a/changelogs/fragments/8413-galaxy-refactor.yml b/changelogs/fragments/8413-galaxy-refactor.yml new file mode 100644 index 0000000000..edd1601be8 --- /dev/null +++ b/changelogs/fragments/8413-galaxy-refactor.yml @@ -0,0 +1,2 @@ +minor_changes: + - ansible_galaxy_install - minor refactor in the module (https://github.com/ansible-collections/community.general/pull/8413). diff --git a/changelogs/fragments/8415-cmd-runner-stack.yml b/changelogs/fragments/8415-cmd-runner-stack.yml new file mode 100644 index 0000000000..555683e057 --- /dev/null +++ b/changelogs/fragments/8415-cmd-runner-stack.yml @@ -0,0 +1,2 @@ +minor_changes: + - cmd_runner module utils - add decorator ``cmd_runner_fmt.stack`` (https://github.com/ansible-collections/community.general/pull/8415). diff --git a/changelogs/fragments/8428-assign-auth-flow-by-name-keycloak-client.yaml b/changelogs/fragments/8428-assign-auth-flow-by-name-keycloak-client.yaml new file mode 100644 index 0000000000..d9bb9bc3ea --- /dev/null +++ b/changelogs/fragments/8428-assign-auth-flow-by-name-keycloak-client.yaml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_client - assign auth flow by name (https://github.com/ansible-collections/community.general/pull/8428). diff --git a/changelogs/fragments/8430-fix-opentelemetry-when-using-logs-with-uri-or-slurp-tasks.yaml b/changelogs/fragments/8430-fix-opentelemetry-when-using-logs-with-uri-or-slurp-tasks.yaml new file mode 100644 index 0000000000..29da61c8bf --- /dev/null +++ b/changelogs/fragments/8430-fix-opentelemetry-when-using-logs-with-uri-or-slurp-tasks.yaml @@ -0,0 +1,3 @@ +bugfixes: + - opentelemetry callback - do not save the JSON response when using the ``ansible.builtin.uri`` module (https://github.com/ansible-collections/community.general/pull/8430). + - opentelemetry callback - do not save the content response when using the ``ansible.builtin.slurp`` module (https://github.com/ansible-collections/community.general/pull/8430). \ No newline at end of file diff --git a/changelogs/fragments/8431-galaxy-upgrade.yml b/changelogs/fragments/8431-galaxy-upgrade.yml new file mode 100644 index 0000000000..9be9ca93c8 --- /dev/null +++ b/changelogs/fragments/8431-galaxy-upgrade.yml @@ -0,0 +1,2 @@ +minor_changes: + - ansible_galaxy_install - add upgrade feature (https://github.com/ansible-collections/community.general/pull/8431, https://github.com/ansible-collections/community.general/issues/8351). diff --git a/changelogs/fragments/8440-allow-api-port-specification.yaml b/changelogs/fragments/8440-allow-api-port-specification.yaml new file mode 100644 index 0000000000..646ee1ab60 --- /dev/null +++ b/changelogs/fragments/8440-allow-api-port-specification.yaml @@ -0,0 +1,2 @@ +minor_changes: + - proxmox - allow specification of the API port when using proxmox_* (https://github.com/ansible-collections/community.general/issues/8440, https://github.com/ansible-collections/community.general/pull/8441). diff --git a/changelogs/fragments/8444-fix-redfish-gen2-upgrade.yaml b/changelogs/fragments/8444-fix-redfish-gen2-upgrade.yaml new file mode 100644 index 0000000000..d094327240 --- /dev/null +++ b/changelogs/fragments/8444-fix-redfish-gen2-upgrade.yaml @@ -0,0 +1,2 @@ +minor_changes: + - wdc_redfish_command - minor change to handle upgrade file for Redfish WD platforms (https://github.com/ansible-collections/community.general/pull/8444). diff --git a/changelogs/fragments/8452-git_config-absent.yml b/changelogs/fragments/8452-git_config-absent.yml new file mode 100644 index 0000000000..11e0767713 --- /dev/null +++ b/changelogs/fragments/8452-git_config-absent.yml @@ -0,0 +1,2 @@ +bugfixes: + - "git_config - fix behavior of ``state=absent`` if ``value`` is present (https://github.com/ansible-collections/community.general/issues/8436, https://github.com/ansible-collections/community.general/pull/8452)." diff --git a/changelogs/fragments/8453-git_config-deprecate-read.yml b/changelogs/fragments/8453-git_config-deprecate-read.yml new file mode 100644 index 0000000000..a291568fce --- /dev/null +++ b/changelogs/fragments/8453-git_config-deprecate-read.yml @@ -0,0 +1,3 @@ +deprecated_features: + - "git_config - the ``list_all`` option has been deprecated and will be removed in community.general 11.0.0. Use the ``community.general.git_config_info`` module instead (https://github.com/ansible-collections/community.general/pull/8453)." + - "git_config - using ``state=present`` without providing ``value`` is deprecated and will be disallowed in community.general 11.0.0. Use the ``community.general.git_config_info`` module instead to read a value (https://github.com/ansible-collections/community.general/pull/8453)." diff --git a/changelogs/fragments/8464-redis-add-cluster-info.yml b/changelogs/fragments/8464-redis-add-cluster-info.yml new file mode 100644 index 0000000000..921307d716 --- /dev/null +++ b/changelogs/fragments/8464-redis-add-cluster-info.yml @@ -0,0 +1,2 @@ +minor_changes: + - redis_info - adds support for getting cluster info (https://github.com/ansible-collections/community.general/pull/8464). diff --git a/changelogs/fragments/8471-proxmox-vm-info-network.yml b/changelogs/fragments/8471-proxmox-vm-info-network.yml new file mode 100644 index 0000000000..f658b78831 --- /dev/null +++ b/changelogs/fragments/8471-proxmox-vm-info-network.yml @@ -0,0 +1,2 @@ +minor_changes: + - proxmox_vm_info - add ``network`` option to retrieve current network information (https://github.com/ansible-collections/community.general/pull/8471). diff --git a/changelogs/fragments/8476-launchd-check-mode-changed.yaml b/changelogs/fragments/8476-launchd-check-mode-changed.yaml new file mode 100644 index 0000000000..dc1e60de36 --- /dev/null +++ b/changelogs/fragments/8476-launchd-check-mode-changed.yaml @@ -0,0 +1,2 @@ +bugfixes: + - launched - correctly report changed status in check mode (https://github.com/ansible-collections/community.general/pull/8406). diff --git a/changelogs/fragments/8479-cmdrunner-improvements.yml b/changelogs/fragments/8479-cmdrunner-improvements.yml new file mode 100644 index 0000000000..075f5f5cd6 --- /dev/null +++ b/changelogs/fragments/8479-cmdrunner-improvements.yml @@ -0,0 +1,4 @@ +deprecated_features: + - CmdRunner module util - setting the value of the ``ignore_none`` parameter within a ``CmdRunner`` context is deprecated and that feature should be removed in community.general 12.0.0 (https://github.com/ansible-collections/community.general/pull/8479). +minor_changes: + - CmdRunner module util - argument formats can be specified as plain functions without calling ``cmd_runner_fmt.as_func()`` (https://github.com/ansible-collections/community.general/pull/8479). diff --git a/changelogs/fragments/8480-directory-feature-cargo.yml b/changelogs/fragments/8480-directory-feature-cargo.yml new file mode 100644 index 0000000000..8892e7c5dd --- /dev/null +++ b/changelogs/fragments/8480-directory-feature-cargo.yml @@ -0,0 +1,2 @@ +minor_changes: + - "cargo - add option ``directory``, which allows source directory to be specified (https://github.com/ansible-collections/community.general/pull/8480)." diff --git a/changelogs/fragments/8489-fix-opennebula-inventory-crash-when-nic-has-no-ip.yml b/changelogs/fragments/8489-fix-opennebula-inventory-crash-when-nic-has-no-ip.yml new file mode 100644 index 0000000000..3db86f364e --- /dev/null +++ b/changelogs/fragments/8489-fix-opennebula-inventory-crash-when-nic-has-no-ip.yml @@ -0,0 +1,2 @@ +bugfixes: + - opennebula inventory plugin - fix invalid reference to IP when inventory runs against NICs with no IPv4 address (https://github.com/ansible-collections/community.general/pull/8489). diff --git a/changelogs/fragments/8496-keycloak_clientscope-add-normalizations.yaml b/changelogs/fragments/8496-keycloak_clientscope-add-normalizations.yaml new file mode 100644 index 0000000000..8af320cae0 --- /dev/null +++ b/changelogs/fragments/8496-keycloak_clientscope-add-normalizations.yaml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_realm - add normalizations for ``attributes`` and ``protocol_mappers`` (https://github.com/ansible-collections/community.general/pull/8496). diff --git a/changelogs/fragments/8497-crypt.yml b/changelogs/fragments/8497-crypt.yml new file mode 100644 index 0000000000..f77f6c20f9 --- /dev/null +++ b/changelogs/fragments/8497-crypt.yml @@ -0,0 +1,3 @@ +known_issues: + - "homectl - the module does not work under Python 3.13 or newer, since it relies on the removed ``crypt`` standard library module (https://github.com/ansible-collections/community.general/issues/4691, https://github.com/ansible-collections/community.general/pull/8497)." + - "udm_user - the module does not work under Python 3.13 or newer, since it relies on the removed ``crypt`` standard library module (https://github.com/ansible-collections/community.general/issues/4690, https://github.com/ansible-collections/community.general/pull/8497)." diff --git a/changelogs/fragments/8508-virtualbox-inventory.yml b/changelogs/fragments/8508-virtualbox-inventory.yml new file mode 100644 index 0000000000..dd14818331 --- /dev/null +++ b/changelogs/fragments/8508-virtualbox-inventory.yml @@ -0,0 +1,3 @@ +minor_changes: + - >- + virtualbox inventory plugin - expose a new parameter ``enable_advanced_group_parsing`` to change how the VirtualBox dynamic inventory parses VM groups (https://github.com/ansible-collections/community.general/issues/8508, https://github.com/ansible-collections/community.general/pull/8510). \ No newline at end of file diff --git a/changelogs/fragments/8512-as-bool-not.yml b/changelogs/fragments/8512-as-bool-not.yml new file mode 100644 index 0000000000..f579c19810 --- /dev/null +++ b/changelogs/fragments/8512-as-bool-not.yml @@ -0,0 +1,2 @@ +minor_changes: + - cmd_runner_fmt module utils - simplify implementation of ``cmd_runner_fmt.as_bool_not()`` (https://github.com/ansible-collections/community.general/pull/8512). diff --git a/changelogs/fragments/8514-pacman-empty.yml b/changelogs/fragments/8514-pacman-empty.yml new file mode 100644 index 0000000000..c51ba21acc --- /dev/null +++ b/changelogs/fragments/8514-pacman-empty.yml @@ -0,0 +1,2 @@ +bugfixes: + - "paman - do not fail if an empty list of packages has been provided and there is nothing to do (https://github.com/ansible-collections/community.general/pull/8514)." diff --git a/changelogs/fragments/8516-proxmox-template-refactor.yml b/changelogs/fragments/8516-proxmox-template-refactor.yml new file mode 100644 index 0000000000..c069985111 --- /dev/null +++ b/changelogs/fragments/8516-proxmox-template-refactor.yml @@ -0,0 +1,2 @@ +minor_changes: + - proxmox_template - small refactor in logic for determining whether a template exists or not (https://github.com/ansible-collections/community.general/pull/8516). diff --git a/changelogs/fragments/8517-cmd-runner-lang-auto.yml b/changelogs/fragments/8517-cmd-runner-lang-auto.yml new file mode 100644 index 0000000000..086a74e997 --- /dev/null +++ b/changelogs/fragments/8517-cmd-runner-lang-auto.yml @@ -0,0 +1,2 @@ +minor_changes: + - CmdRunner module utils - the parameter ``force_lang`` now supports the special value ``auto`` which will automatically try and determine the best parsable locale in the system (https://github.com/ansible-collections/community.general/pull/8517). diff --git a/changelogs/fragments/8533-add-ciphers-option.yml b/changelogs/fragments/8533-add-ciphers-option.yml new file mode 100644 index 0000000000..7f9880ebee --- /dev/null +++ b/changelogs/fragments/8533-add-ciphers-option.yml @@ -0,0 +1,4 @@ +--- +minor_changes: + - redfish_* modules - adds ``ciphers`` option for custom cipher selection (https://github.com/ansible-collections/community.general/pull/8533). +... diff --git a/changelogs/fragments/8542-fix-proxmox-volume-handling.yml b/changelogs/fragments/8542-fix-proxmox-volume-handling.yml new file mode 100644 index 0000000000..9b982c0aeb --- /dev/null +++ b/changelogs/fragments/8542-fix-proxmox-volume-handling.yml @@ -0,0 +1,5 @@ +bugfixes: + - proxmox - fix idempotency on creation of mount volumes using Proxmox' special ``:`` syntax (https://github.com/ansible-collections/community.general/issues/8407, https://github.com/ansible-collections/community.general/pull/8542). +minor_changes: + - proxmox - add ``disk_volume`` and ``mount_volumes`` keys for better readability (https://github.com/ansible-collections/community.general/pull/8542). + - proxmox - translate the old ``disk`` and ``mounts`` keys to the new handling internally (https://github.com/ansible-collections/community.general/pull/8542). diff --git a/changelogs/fragments/8545-keycloak-clientscope-remove-id-on-compare.yml b/changelogs/fragments/8545-keycloak-clientscope-remove-id-on-compare.yml new file mode 100644 index 0000000000..5986a45b87 --- /dev/null +++ b/changelogs/fragments/8545-keycloak-clientscope-remove-id-on-compare.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_clientscope - remove IDs from clientscope and its protocol mappers on comparison for changed check (https://github.com/ansible-collections/community.general/pull/8545). diff --git a/changelogs/fragments/8557-fix-bug-with-bitwarden.yml b/changelogs/fragments/8557-fix-bug-with-bitwarden.yml new file mode 100644 index 0000000000..cf41ae209f --- /dev/null +++ b/changelogs/fragments/8557-fix-bug-with-bitwarden.yml @@ -0,0 +1,2 @@ +bugfixes: + - "bitwarden lookup plugin - fix ``KeyError`` in ``search_field`` (https://github.com/ansible-collections/community.general/issues/8549, https://github.com/ansible-collections/community.general/pull/8557)." \ No newline at end of file diff --git a/changelogs/fragments/8613-redfish_utils-language.yaml b/changelogs/fragments/8613-redfish_utils-language.yaml new file mode 100644 index 0000000000..1fc43c895d --- /dev/null +++ b/changelogs/fragments/8613-redfish_utils-language.yaml @@ -0,0 +1,2 @@ +bugfixes: + - redfish_utils module utils - do not fail when language is not exactly "en" (https://github.com/ansible-collections/community.general/pull/8613). diff --git a/changelogs/fragments/8614-nsupdate-index-out-of-range.yml b/changelogs/fragments/8614-nsupdate-index-out-of-range.yml new file mode 100644 index 0000000000..00b6f8b974 --- /dev/null +++ b/changelogs/fragments/8614-nsupdate-index-out-of-range.yml @@ -0,0 +1,2 @@ +bugfixes: + - "nsupdate - fix 'index out of range' error when changing NS records by falling back to authority section of the response (https://github.com/ansible-collections/community.general/issues/8612, https://github.com/ansible-collections/community.general/pull/8614)." diff --git a/changelogs/fragments/8623-become-types.yml b/changelogs/fragments/8623-become-types.yml new file mode 100644 index 0000000000..c38e67eca1 --- /dev/null +++ b/changelogs/fragments/8623-become-types.yml @@ -0,0 +1,2 @@ +minor_changes: + - "doas, dzdo, ksu, machinectl, pbrun, pfexec, pmrun, sesu, sudosu become plugins - make sure that all options are typed (https://github.com/ansible-collections/community.general/pull/8623)." diff --git a/changelogs/fragments/8624-cache-types.yml b/changelogs/fragments/8624-cache-types.yml new file mode 100644 index 0000000000..8efa34b6c0 --- /dev/null +++ b/changelogs/fragments/8624-cache-types.yml @@ -0,0 +1,2 @@ +minor_changes: + - "memcached, pickle, redis, yaml cache plugins - make sure that all options are typed (https://github.com/ansible-collections/community.general/pull/8624)." diff --git a/changelogs/fragments/8625-inventory-types.yml b/changelogs/fragments/8625-inventory-types.yml new file mode 100644 index 0000000000..a89352a230 --- /dev/null +++ b/changelogs/fragments/8625-inventory-types.yml @@ -0,0 +1,2 @@ +minor_changes: + - "cobbler, linode, lxd, nmap, online, scaleway, stackpath_compute, virtualbox inventory plugins - make sure that all options are typed (https://github.com/ansible-collections/community.general/pull/8625)." diff --git a/changelogs/fragments/8626-lookup-types.yml b/changelogs/fragments/8626-lookup-types.yml new file mode 100644 index 0000000000..b6ebf35748 --- /dev/null +++ b/changelogs/fragments/8626-lookup-types.yml @@ -0,0 +1,2 @@ +minor_changes: + - "chef_databag, consul_kv, cyberarkpassword, dsv, etcd, filetree, hiera, onepassword, onepassword_doc, onepassword_raw, passwordstore, redis, shelvefile, tss lookup plugins - make sure that all options are typed (https://github.com/ansible-collections/community.general/pull/8626)." diff --git a/changelogs/fragments/8627-connection-types.yml b/changelogs/fragments/8627-connection-types.yml new file mode 100644 index 0000000000..9b92735fb8 --- /dev/null +++ b/changelogs/fragments/8627-connection-types.yml @@ -0,0 +1,2 @@ +minor_changes: + - "chroot, funcd, incus, iocage, jail, lxc, lxd, qubes, zone connection plugins - make sure that all options are typed (https://github.com/ansible-collections/community.general/pull/8627)." diff --git a/changelogs/fragments/8628-callback-types.yml b/changelogs/fragments/8628-callback-types.yml new file mode 100644 index 0000000000..c223a85985 --- /dev/null +++ b/changelogs/fragments/8628-callback-types.yml @@ -0,0 +1,2 @@ +minor_changes: + - "cgroup_memory_recap, hipchat, jabber, log_plays, loganalytics, logentries, logstash, slack, splunk, sumologic, syslog_json callback plugins - make sure that all options are typed (https://github.com/ansible-collections/community.general/pull/8628)." diff --git a/changelogs/fragments/8632-pkgng-add-option-use_globs.yml b/changelogs/fragments/8632-pkgng-add-option-use_globs.yml new file mode 100644 index 0000000000..d3e03959d5 --- /dev/null +++ b/changelogs/fragments/8632-pkgng-add-option-use_globs.yml @@ -0,0 +1,2 @@ +minor_changes: + - pkgng - add option ``use_globs`` (default ``true``) to optionally disable glob patterns (https://github.com/ansible-collections/community.general/issues/8632, https://github.com/ansible-collections/community.general/pull/8633). diff --git a/changelogs/fragments/8646-fix-bug-in-proxmox-volumes.yml b/changelogs/fragments/8646-fix-bug-in-proxmox-volumes.yml new file mode 100644 index 0000000000..b3b03a008b --- /dev/null +++ b/changelogs/fragments/8646-fix-bug-in-proxmox-volumes.yml @@ -0,0 +1,4 @@ +bugfixes: + - proxmox - removed the forced conversion of non-string values to strings to be consistent with the module documentation (https://github.com/ansible-collections/community.general/pull/8646). + - proxmox - fixed an issue where the new volume handling incorrectly converted ``null`` values into ``"None"`` strings (https://github.com/ansible-collections/community.general/pull/8646). + - proxmox - fixed an issue where volume strings where overwritten instead of appended to in the new ``build_volume()`` method (https://github.com/ansible-collections/community.general/pull/8646). diff --git a/changelogs/fragments/8648-fix-gitlab-runner-paused.yaml b/changelogs/fragments/8648-fix-gitlab-runner-paused.yaml new file mode 100644 index 0000000000..d064725f14 --- /dev/null +++ b/changelogs/fragments/8648-fix-gitlab-runner-paused.yaml @@ -0,0 +1,2 @@ +bugfixes: + - "gitlab_runner - fix ``paused`` parameter being ignored (https://github.com/ansible-collections/community.general/pull/8648)." \ No newline at end of file diff --git a/changelogs/fragments/8654-add-redis-tls-params.yml b/changelogs/fragments/8654-add-redis-tls-params.yml new file mode 100644 index 0000000000..0b549f5dd0 --- /dev/null +++ b/changelogs/fragments/8654-add-redis-tls-params.yml @@ -0,0 +1,2 @@ +minor_changes: + - redis, redis_info - add ``client_cert`` and ``client_key`` options to specify path to certificate for Redis authentication (https://github.com/ansible-collections/community.general/pull/8654). diff --git a/changelogs/fragments/8674-add-gitlab-project-cleanup-policy.yml b/changelogs/fragments/8674-add-gitlab-project-cleanup-policy.yml new file mode 100644 index 0000000000..f67e11a6b0 --- /dev/null +++ b/changelogs/fragments/8674-add-gitlab-project-cleanup-policy.yml @@ -0,0 +1,3 @@ +minor_changes: + - gitlab_project - add option ``repository_access_level`` to disable project repository (https://github.com/ansible-collections/community.general/pull/8674). + - gitlab_project - add option ``container_expiration_policy`` to schedule container registry cleanup (https://github.com/ansible-collections/community.general/pull/8674). diff --git a/changelogs/fragments/8675-pipx-install-suffix.yml b/changelogs/fragments/8675-pipx-install-suffix.yml new file mode 100644 index 0000000000..4b5a9a99bc --- /dev/null +++ b/changelogs/fragments/8675-pipx-install-suffix.yml @@ -0,0 +1,2 @@ +minor_changes: + - pipx - add parameter ``suffix`` to module (https://github.com/ansible-collections/community.general/pull/8675, https://github.com/ansible-collections/community.general/issues/8656). diff --git a/changelogs/fragments/8682-locale-gen-multiple.yaml b/changelogs/fragments/8682-locale-gen-multiple.yaml new file mode 100644 index 0000000000..139f372353 --- /dev/null +++ b/changelogs/fragments/8682-locale-gen-multiple.yaml @@ -0,0 +1,2 @@ +minor_changes: + - locale_gen - add support for multiple locales (https://github.com/ansible-collections/community.general/issues/8677, https://github.com/ansible-collections/community.general/pull/8682). diff --git a/changelogs/fragments/8688-gitlab_project-add-new-params.yml b/changelogs/fragments/8688-gitlab_project-add-new-params.yml new file mode 100644 index 0000000000..0c6b8e505a --- /dev/null +++ b/changelogs/fragments/8688-gitlab_project-add-new-params.yml @@ -0,0 +1,4 @@ +minor_changes: + - gitlab_project - add option ``pages_access_level`` to disable project pages (https://github.com/ansible-collections/community.general/pull/8688). + - gitlab_project - add option ``service_desk_enabled`` to disable service desk (https://github.com/ansible-collections/community.general/pull/8688). + - gitlab_project - add option ``model_registry_access_level`` to disable model registry (https://github.com/ansible-collections/community.general/pull/8688). diff --git a/changelogs/fragments/8689-passwordstore-lock-naming.yml b/changelogs/fragments/8689-passwordstore-lock-naming.yml new file mode 100644 index 0000000000..c5c9a82d78 --- /dev/null +++ b/changelogs/fragments/8689-passwordstore-lock-naming.yml @@ -0,0 +1,2 @@ +minor_changes: + - passwordstore lookup plugin - add the current user to the lockfile file name to address issues on multi-user systems (https://github.com/ansible-collections/community.general/pull/8689). diff --git a/changelogs/fragments/8695-keycloak_user_federation-mapper-removal.yml b/changelogs/fragments/8695-keycloak_user_federation-mapper-removal.yml new file mode 100644 index 0000000000..b518d59e36 --- /dev/null +++ b/changelogs/fragments/8695-keycloak_user_federation-mapper-removal.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_user_federation - remove existing user federation mappers if they are not present in the federation configuration and will not be updated (https://github.com/ansible-collections/community.general/issues/7169, https://github.com/ansible-collections/community.general/pull/8695). \ No newline at end of file diff --git a/changelogs/fragments/8708-homebrew_cask-fix-upgrade-all.yml b/changelogs/fragments/8708-homebrew_cask-fix-upgrade-all.yml new file mode 100644 index 0000000000..6a0cd74302 --- /dev/null +++ b/changelogs/fragments/8708-homebrew_cask-fix-upgrade-all.yml @@ -0,0 +1,2 @@ +bugfixes: + - homebrew_cask - fix ``upgrade_all`` returns ``changed`` when nothing upgraded (https://github.com/ansible-collections/community.general/issues/8707, https://github.com/ansible-collections/community.general/pull/8708). \ No newline at end of file diff --git a/changelogs/fragments/8711-gconftool2-refactor.yml b/changelogs/fragments/8711-gconftool2-refactor.yml new file mode 100644 index 0000000000..ae214d95ec --- /dev/null +++ b/changelogs/fragments/8711-gconftool2-refactor.yml @@ -0,0 +1,2 @@ +minor_changes: + - gconftool2 - make use of ``ModuleHelper`` features to simplify code (https://github.com/ansible-collections/community.general/pull/8711). diff --git a/changelogs/fragments/8713-proxmox_lxc_interfaces.yml b/changelogs/fragments/8713-proxmox_lxc_interfaces.yml new file mode 100644 index 0000000000..32c475157e --- /dev/null +++ b/changelogs/fragments/8713-proxmox_lxc_interfaces.yml @@ -0,0 +1,2 @@ +minor_changes: + - proxmox inventory plugin - add new fact for LXC interface details (https://github.com/ansible-collections/community.general/pull/8713). \ No newline at end of file diff --git a/changelogs/fragments/8735-keycloak_identity_provider-get-cleartext-secret-from-realm-info.yml b/changelogs/fragments/8735-keycloak_identity_provider-get-cleartext-secret-from-realm-info.yml new file mode 100644 index 0000000000..ed3806bd5f --- /dev/null +++ b/changelogs/fragments/8735-keycloak_identity_provider-get-cleartext-secret-from-realm-info.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_user_federation - get cleartext IDP ``clientSecret`` from full realm info to detect changes to it (https://github.com/ansible-collections/community.general/issues/8294, https://github.com/ansible-collections/community.general/pull/8735). \ No newline at end of file diff --git a/changelogs/fragments/8738-limit-packages-for-copr.yml b/changelogs/fragments/8738-limit-packages-for-copr.yml new file mode 100644 index 0000000000..0e49cc5cd9 --- /dev/null +++ b/changelogs/fragments/8738-limit-packages-for-copr.yml @@ -0,0 +1,2 @@ +minor_changes: + - copr - Added ``includepkgs`` and ``excludepkgs`` parameters to limit the list of packages fetched or excluded from the repository(https://github.com/ansible-collections/community.general/pull/8779). \ No newline at end of file diff --git a/changelogs/fragments/8741-fix-opentelemetry-callback.yml b/changelogs/fragments/8741-fix-opentelemetry-callback.yml new file mode 100644 index 0000000000..1b5e63a89f --- /dev/null +++ b/changelogs/fragments/8741-fix-opentelemetry-callback.yml @@ -0,0 +1,2 @@ +minor_changes: + - opentelemetry callback plugin - fix default value for ``store_spans_in_file`` causing traces to be produced to a file named ``None`` (https://github.com/ansible-collections/community.general/issues/8566, https://github.com/ansible-collections/community.general/pull/8741). diff --git a/changelogs/fragments/8759-gitlab_project-sort-params.yml b/changelogs/fragments/8759-gitlab_project-sort-params.yml new file mode 100644 index 0000000000..2ff2ed18a7 --- /dev/null +++ b/changelogs/fragments/8759-gitlab_project-sort-params.yml @@ -0,0 +1,2 @@ +minor_changes: + - gitlab_project - sorted parameters in order to avoid future merge conflicts (https://github.com/ansible-collections/community.general/pull/8759). diff --git a/changelogs/fragments/8760-gitlab_project-add-issues-access-level.yml b/changelogs/fragments/8760-gitlab_project-add-issues-access-level.yml new file mode 100644 index 0000000000..1a77b2f0d4 --- /dev/null +++ b/changelogs/fragments/8760-gitlab_project-add-issues-access-level.yml @@ -0,0 +1,2 @@ +minor_changes: + - gitlab_project - add option ``issues_access_level`` to enable/disable project issues (https://github.com/ansible-collections/community.general/pull/8760). diff --git a/changelogs/fragments/8761-keycloak_user_federation-sort-desired-and-after-mappers-by-name.yml b/changelogs/fragments/8761-keycloak_user_federation-sort-desired-and-after-mappers-by-name.yml new file mode 100644 index 0000000000..2d7d39345f --- /dev/null +++ b/changelogs/fragments/8761-keycloak_user_federation-sort-desired-and-after-mappers-by-name.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_user_federation - sort desired and after mapper list by name (analog to before mapper list) to minimize diff and make change detection more accurate (https://github.com/ansible-collections/community.general/pull/8761). \ No newline at end of file diff --git a/changelogs/fragments/8762-keycloac_user_federation-fix-key-error-when-updating.yml b/changelogs/fragments/8762-keycloac_user_federation-fix-key-error-when-updating.yml new file mode 100644 index 0000000000..08da8ae21a --- /dev/null +++ b/changelogs/fragments/8762-keycloac_user_federation-fix-key-error-when-updating.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_user_federation - fix key error when removing mappers during an update and new mappers are specified in the module args (https://github.com/ansible-collections/community.general/pull/8762). \ No newline at end of file diff --git a/changelogs/fragments/8764-keycloak_user_federation-make-mapper-removal-optout.yml b/changelogs/fragments/8764-keycloak_user_federation-make-mapper-removal-optout.yml new file mode 100644 index 0000000000..c457012751 --- /dev/null +++ b/changelogs/fragments/8764-keycloak_user_federation-make-mapper-removal-optout.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_user_federation - add module argument allowing users to optout of the removal of unspecified mappers, for example to keep the keycloak default mappers (https://github.com/ansible-collections/community.general/pull/8764). \ No newline at end of file diff --git a/changelogs/fragments/8766-mh-deco-improve.yml b/changelogs/fragments/8766-mh-deco-improve.yml new file mode 100644 index 0000000000..7bf104d2cc --- /dev/null +++ b/changelogs/fragments/8766-mh-deco-improve.yml @@ -0,0 +1,3 @@ +minor_changes: + - MH module utils - add parameter ``when`` to ``cause_changes`` decorator (https://github.com/ansible-collections/community.general/pull/8766). + - MH module utils - minor refactor in decorators (https://github.com/ansible-collections/community.general/pull/8766). diff --git a/changelogs/fragments/8776-mute-vardict-deprecation.yml b/changelogs/fragments/8776-mute-vardict-deprecation.yml new file mode 100644 index 0000000000..a74e40e923 --- /dev/null +++ b/changelogs/fragments/8776-mute-vardict-deprecation.yml @@ -0,0 +1,3 @@ +minor_changes: + - jira - mute the old ``VarDict`` deprecation (https://github.com/ansible-collections/community.general/pull/8776). + - gio_mime - mute the old ``VarDict`` deprecation (https://github.com/ansible-collections/community.general/pull/8776). diff --git a/changelogs/fragments/8790-gitlab_project-fix-cleanup-policy-on-project-create.yml b/changelogs/fragments/8790-gitlab_project-fix-cleanup-policy-on-project-create.yml new file mode 100644 index 0000000000..ba171a1178 --- /dev/null +++ b/changelogs/fragments/8790-gitlab_project-fix-cleanup-policy-on-project-create.yml @@ -0,0 +1,3 @@ +bugfixes: + - gitlab_project - fix crash caused by old Gitlab projects not having a ``container_expiration_policy`` attribute (https://github.com/ansible-collections/community.general/pull/8790). + - gitlab_project - fix ``container_expiration_policy`` not being applied when creating a new project (https://github.com/ansible-collections/community.general/pull/8790). diff --git a/changelogs/fragments/8791-mh-cause-changes-param-depr.yml b/changelogs/fragments/8791-mh-cause-changes-param-depr.yml new file mode 100644 index 0000000000..7f7935af14 --- /dev/null +++ b/changelogs/fragments/8791-mh-cause-changes-param-depr.yml @@ -0,0 +1,4 @@ +minor_changes: + - jira - replace deprecated params when using decorator ``cause_changes`` (https://github.com/ansible-collections/community.general/pull/8791). +deprecated_features: + - MH decorator cause_changes module utils - deprecate parameters ``on_success`` and ``on_failure`` (https://github.com/ansible-collections/community.general/pull/8791). diff --git a/changelogs/fragments/8793-pipx-global.yml b/changelogs/fragments/8793-pipx-global.yml new file mode 100644 index 0000000000..c3d7f5157f --- /dev/null +++ b/changelogs/fragments/8793-pipx-global.yml @@ -0,0 +1,12 @@ +minor_changes: + - pipx - added parameter ``global`` to module (https://github.com/ansible-collections/community.general/pull/8793). + - pipx_info - added parameter ``global`` to module (https://github.com/ansible-collections/community.general/pull/8793). +deprecated_features: + - > + pipx - + support for versions of the command line tool ``pipx`` older than ``1.7.0`` is deprecated and will be removed in community.general 11.0.0 + (https://github.com/ansible-collections/community.general/pull/8793). + - > + pipx_info - + support for versions of the command line tool ``pipx`` older than ``1.7.0`` is deprecated and will be removed in community.general 11.0.0 + (https://github.com/ansible-collections/community.general/pull/8793). diff --git a/changelogs/fragments/8794-Fixing-possible-concatination-error.yaml b/changelogs/fragments/8794-Fixing-possible-concatination-error.yaml new file mode 100644 index 0000000000..a94eace415 --- /dev/null +++ b/changelogs/fragments/8794-Fixing-possible-concatination-error.yaml @@ -0,0 +1,2 @@ +bugfixes: + - proxmox inventory plugin - fixed a possible error on concatenating responses from proxmox. In case an API call unexpectedly returned an empty result, the inventory failed with a fatal error. Added check for empty response (https://github.com/ansible-collections/community.general/issues/8798, https://github.com/ansible-collections/community.general/pull/8794). diff --git a/changelogs/fragments/8796-gitlab-access-token-check-mode.yml b/changelogs/fragments/8796-gitlab-access-token-check-mode.yml new file mode 100644 index 0000000000..6585584fac --- /dev/null +++ b/changelogs/fragments/8796-gitlab-access-token-check-mode.yml @@ -0,0 +1,3 @@ +bugfixes: + - gitlab_group_access_token - fix crash in check mode caused by attempted access to a newly created access token (https://github.com/ansible-collections/community.general/pull/8796). + - gitlab_project_access_token - fix crash in check mode caused by attempted access to a newly created access token (https://github.com/ansible-collections/community.general/pull/8796). diff --git a/changelogs/fragments/8809-pipx-new-params.yml b/changelogs/fragments/8809-pipx-new-params.yml new file mode 100644 index 0000000000..775163e987 --- /dev/null +++ b/changelogs/fragments/8809-pipx-new-params.yml @@ -0,0 +1,2 @@ +minor_changes: + - pipx - added new states ``install_all``, ``uninject``, ``upgrade_shared``, ``pin``, and ``unpin`` (https://github.com/ansible-collections/community.general/pull/8809). diff --git a/changelogs/fragments/8814-dict-comprehension.yml b/changelogs/fragments/8814-dict-comprehension.yml new file mode 100644 index 0000000000..01b5da4bae --- /dev/null +++ b/changelogs/fragments/8814-dict-comprehension.yml @@ -0,0 +1,23 @@ +minor_changes: + - hashids filter plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - keep_keys filter plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - remove_keys filter plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - replace_keys filter plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - csv module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - vars MH module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - vardict module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - apache2_mod_proxy - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - gitlab_group - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - keycloak_client - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - keycloak_clientscope - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - keycloak_identity_provider - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - keycloak_user_federation - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - linode - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - lxd_container - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - manageiq_provider - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - one_service - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - one_vm - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - proxmox - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - proxmox_disk - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - proxmox_kvm - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). + - unsafe plugin utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8814). diff --git a/changelogs/fragments/8822-dict-comprehension.yml b/changelogs/fragments/8822-dict-comprehension.yml new file mode 100644 index 0000000000..cefb673bb8 --- /dev/null +++ b/changelogs/fragments/8822-dict-comprehension.yml @@ -0,0 +1,21 @@ +minor_changes: + - credstash lookup plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - keycloak module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - deco MH module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - redfish_utils module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - scaleway module utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - etcd3 - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - gitlab_project - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - hwc_ecs_instance - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - hwc_evs_disk - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - hwc_vpc_eip - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - hwc_vpc_peering_connect - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - hwc_vpc_port - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - hwc_vpc_subnet - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - ipa_otptoken - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - keycloak_user_federation - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - lxc_container - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - proxmox_kvm - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - scaleway_security_group - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - ufw - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). + - vmadm - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8822). diff --git a/changelogs/fragments/8823-keycloak-realm-key.yml b/changelogs/fragments/8823-keycloak-realm-key.yml new file mode 100644 index 0000000000..4c0e591f8e --- /dev/null +++ b/changelogs/fragments/8823-keycloak-realm-key.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_realm_key - fix invalid usage of ``parent_id`` (https://github.com/ansible-collections/community.general/issues/7850, https://github.com/ansible-collections/community.general/pull/8823). \ No newline at end of file diff --git a/changelogs/fragments/8831-fix-error-when-mapper-id-is-provided.yml b/changelogs/fragments/8831-fix-error-when-mapper-id-is-provided.yml new file mode 100644 index 0000000000..63ac352057 --- /dev/null +++ b/changelogs/fragments/8831-fix-error-when-mapper-id-is-provided.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_user_federation - fix the ``UnboundLocalError`` that occurs when an ID is provided for a user federation mapper (https://github.com/ansible-collections/community.general/pull/8831). \ No newline at end of file diff --git a/changelogs/fragments/8833-dict-comprehension.yml b/changelogs/fragments/8833-dict-comprehension.yml new file mode 100644 index 0000000000..1515609e69 --- /dev/null +++ b/changelogs/fragments/8833-dict-comprehension.yml @@ -0,0 +1,23 @@ +minor_changes: + - redis cache plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - onepassword lookup plugin - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - ocapi_utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - redfish_utils - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - scaleway - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - alternatives - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - apache2_mod_proxy - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - consul_acl - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - imc_rest - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - keycloak_user_federation - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - pids - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - pipx - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - pipx_info - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - pkg5_publisher - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - scaleway_compute - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - scaleway_ip - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - scaleway_lb - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - scaleway_security_group - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - scaleway_user_data - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - sensu_silence - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - snmp_facts - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). + - sorcery - replace Python 2.6 construct with dict comprehensions (https://github.com/ansible-collections/community.general/pull/8833). diff --git a/changelogs/fragments/add-ipa-sudorule-deny-cmd.yml b/changelogs/fragments/add-ipa-sudorule-deny-cmd.yml deleted file mode 100644 index 2d5dc6205c..0000000000 --- a/changelogs/fragments/add-ipa-sudorule-deny-cmd.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - ipa_sudorule - adds options to include denied commands or command groups (https://github.com/ansible-collections/community.general/pull/7415). diff --git a/changelogs/fragments/aix_filesystem-crfs-issue.yml b/changelogs/fragments/aix_filesystem-crfs-issue.yml deleted file mode 100644 index 6b3ddfb0d6..0000000000 --- a/changelogs/fragments/aix_filesystem-crfs-issue.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -bugfixes: - - aix_filesystem - fix issue with empty list items in crfs logic and option order (https://github.com/ansible-collections/community.general/pull/8052). diff --git a/changelogs/fragments/bitwarden-lookup-performance.yaml b/changelogs/fragments/bitwarden-lookup-performance.yaml deleted file mode 100644 index cb0405b1cb..0000000000 --- a/changelogs/fragments/bitwarden-lookup-performance.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "bitwarden lookup plugin - when looking for items using an item ID, the item is now accessed directly with ``bw get item`` instead of searching through all items. This doubles the lookup speed (https://github.com/ansible-collections/community.general/pull/7468)." diff --git a/changelogs/fragments/internal-redirects.yml b/changelogs/fragments/internal-redirects.yml deleted file mode 100644 index 23ce456d4e..0000000000 --- a/changelogs/fragments/internal-redirects.yml +++ /dev/null @@ -1,5 +0,0 @@ -removed_features: - - "The deprecated redirects for internal module names have been removed. - These internal redirects were extra-long FQCNs like ``community.general.packaging.os.apt_rpm`` that redirect to the short FQCN ``community.general.apt_rpm``. - They were originally needed to implement flatmapping; as various tooling started to recommend users to use the long names flatmapping was removed from the collection - and redirects were added for users who already followed these incorrect recommendations (https://github.com/ansible-collections/community.general/pull/7835)." diff --git a/changelogs/fragments/inventory-rce.yml b/changelogs/fragments/inventory-rce.yml deleted file mode 100644 index 9eee6dff52..0000000000 --- a/changelogs/fragments/inventory-rce.yml +++ /dev/null @@ -1,6 +0,0 @@ -security_fixes: - - "cobbler, gitlab_runners, icinga2, linode, lxd, nmap, online, opennebula, proxmox, scaleway, stackpath_compute, virtualbox, - and xen_orchestra inventory plugin - make sure all data received from the remote servers is marked as unsafe, so remote - code execution by obtaining texts that can be evaluated as templates is not possible - (https://www.die-welt.net/2024/03/remote-code-execution-in-ansible-dynamic-inventory-plugins/, - https://github.com/ansible-collections/community.general/pull/8098)." diff --git a/changelogs/fragments/lxd-instance-not-found-avoid-false-positives.yml b/changelogs/fragments/lxd-instance-not-found-avoid-false-positives.yml deleted file mode 100644 index 03ac8ee01b..0000000000 --- a/changelogs/fragments/lxd-instance-not-found-avoid-false-positives.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "lxd connection plugin - tighten the detection logic for lxd ``Instance not found`` errors, to avoid false detection on unrelated errors such as ``/usr/bin/python3: not found`` (https://github.com/ansible-collections/community.general/pull/7521)." diff --git a/changelogs/fragments/lxd-instances-api-endpoint-added.yml b/changelogs/fragments/lxd-instances-api-endpoint-added.yml deleted file mode 100644 index 3e7aa3b50e..0000000000 --- a/changelogs/fragments/lxd-instances-api-endpoint-added.yml +++ /dev/null @@ -1,2 +0,0 @@ -minor_changes: - - "lxd_container - uses ``/1.0/instances`` API endpoint, if available. Falls back to ``/1.0/containers`` or ``/1.0/virtual-machines``. Fixes issue when using Incus or LXD 5.19 due to migrating to ``/1.0/instances`` endpoint (https://github.com/ansible-collections/community.general/pull/7980)." diff --git a/changelogs/fragments/pacemaker-cluster.yml b/changelogs/fragments/pacemaker-cluster.yml deleted file mode 100644 index 07e1ff3e04..0000000000 --- a/changelogs/fragments/pacemaker-cluster.yml +++ /dev/null @@ -1,3 +0,0 @@ -bugfixes: - - "pacemaker_cluster - actually implement check mode, which the module claims to support. This means that until now the module - also did changes in check mode (https://github.com/ansible-collections/community.general/pull/8081)." diff --git a/changelogs/fragments/pkgin.yml b/changelogs/fragments/pkgin.yml deleted file mode 100644 index 60eff0bfe5..0000000000 --- a/changelogs/fragments/pkgin.yml +++ /dev/null @@ -1,2 +0,0 @@ -bugfixes: - - pkgin - pkgin (pkgsrc package manager used by SmartOS) raises erratic exceptions and spurious ``changed=true`` (https://github.com/ansible-collections/community.general/pull/7971). diff --git a/docs/docsite/config.yml b/docs/docsite/config.yml new file mode 100644 index 0000000000..1d6cf8554a --- /dev/null +++ b/docs/docsite/config.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +changelog: + write_changelog: true diff --git a/docs/docsite/extra-docs.yml b/docs/docsite/extra-docs.yml index 529573606c..f73d0fe012 100644 --- a/docs/docsite/extra-docs.yml +++ b/docs/docsite/extra-docs.yml @@ -14,3 +14,9 @@ sections: - guide_online - guide_packet - guide_scaleway + - title: Developer Guides + toctree: + - guide_deps + - guide_vardict + - guide_cmdrunner + - guide_modulehelper diff --git a/docs/docsite/helper/keep_keys/README.md b/docs/docsite/helper/keep_keys/README.md new file mode 100644 index 0000000000..69a4076ef9 --- /dev/null +++ b/docs/docsite/helper/keep_keys/README.md @@ -0,0 +1,61 @@ + + +# Docs helper. Create RST file. + +The playbook `playbook.yml` writes a RST file that can be used in +docs/docsite/rst. The usage of this helper is recommended but not +mandatory. You can stop reading here and update the RST file manually +if you don't want to use this helper. + +## Run the playbook + +If you want to generate the RST file by this helper fit the variables +in the playbook and the template to your needs. Then, run the play + +```sh +shell> ansible-playbook playbook.yml +``` + +## Copy RST to docs/docsite/rst + +Copy the RST file to `docs/docsite/rst` and remove it from this +directory. + +## Update the checksums + +Substitute the variables and run the below commands + +```sh +shell> sha1sum {{ target_vars }} > {{ target_sha1 }} +shell> sha1sum {{ file_rst }} > {{ file_sha1 }} +``` + +## Playbook explained + +The playbook includes the variable *tests* from the integration tests +and creates the RST file from the template. The playbook will +terminate if: + +* The file with the variable *tests* was changed +* The RST file was changed + +This means that this helper is probably not up to date. + +### The file with the variable *tests* was changed + +This means that somebody updated the integration tests. Review the +changes and update the template if needed. Update the checksum to pass +the integrity test. The playbook message provides you with the +command. + +### The RST file was changed + +This means that somebody updated the RST file manually. Review the +changes and update the template. Update the checksum to pass the +integrity test. The playbook message provides you with the +command. Make sure that the updated template will create identical RST +file. Only then apply your changes. diff --git a/docs/docsite/helper/keep_keys/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst.j2 b/docs/docsite/helper/keep_keys/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst.j2 new file mode 100644 index 0000000000..77281549ba --- /dev/null +++ b/docs/docsite/helper/keep_keys/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst.j2 @@ -0,0 +1,80 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +keep_keys +""""""""" + +Use the filter :ansplugin:`community.general.keep_keys#filter` if you have a list of dictionaries and want to keep certain keys only. + +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See :ansplugin:`the documentation for the community.general.yaml callback plugin `. + + +Let us use the below list in the following examples: + +.. code-block:: yaml + + input: + {{ tests.0.input | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[0:1]|subelements('group') %} +* {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1 + + target: {{ i.1.tt }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.0.result | to_yaml(indent=2) | indent(5) }} + +.. versionadded:: 9.1.0 + +* The results of the below examples 1-5 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.1.result | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[1:2]|subelements('group') %} +{{ loop.index }}. {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: {{ i.1.mp }} + target: {{ i.1.tt }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +* The results of the below examples 6-9 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.2.result | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[2:3]|subelements('group') %} +{{ loop.index + 5 }}. {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: {{ i.1.mp }} + target: {{ i.1.tt }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} diff --git a/docs/docsite/helper/keep_keys/keep_keys.rst.sha1 b/docs/docsite/helper/keep_keys/keep_keys.rst.sha1 new file mode 100644 index 0000000000..532c6a192c --- /dev/null +++ b/docs/docsite/helper/keep_keys/keep_keys.rst.sha1 @@ -0,0 +1 @@ +8690afce792abc95693c2f61f743ee27388b1592 ../../rst/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst diff --git a/docs/docsite/helper/keep_keys/keep_keys.rst.sha1.license b/docs/docsite/helper/keep_keys/keep_keys.rst.sha1.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/docs/docsite/helper/keep_keys/keep_keys.rst.sha1.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/docs/docsite/helper/keep_keys/playbook.yml b/docs/docsite/helper/keep_keys/playbook.yml new file mode 100644 index 0000000000..75ef90385b --- /dev/null +++ b/docs/docsite/helper/keep_keys/playbook.yml @@ -0,0 +1,79 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Create docs REST files +# shell> ansible-playbook playbook.yml +# +# Proofread and copy created *.rst file into the directory +# docs/docsite/rst. Do not add *.rst in this directory to the version +# control. +# +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# community.general/docs/docsite/helper/keep_keys/playbook.yml + +- name: Create RST file for docs/docsite/rst + hosts: localhost + gather_facts: false + + vars: + + plugin: keep_keys + plugin_type: filter + docs_path: + - filter_guide + - abstract_informations + - lists_of_dictionaries + + file_base: "{{ (docs_path + [plugin]) | join('-') }}" + file_rst: ../../rst/{{ file_base }}.rst + file_sha1: "{{ plugin }}.rst.sha1" + + target: "../../../../tests/integration/targets/{{ plugin_type }}_{{ plugin }}" + target_vars: "{{ target }}/vars/main/tests.yml" + target_sha1: tests.yml.sha1 + + tasks: + + - name: Test integrity tests.yml + when: + - integrity | d(true) | bool + - lookup('file', target_sha1) != lookup('pipe', 'sha1sum ' ~ target_vars) + block: + + - name: Changed tests.yml + ansible.builtin.debug: + msg: | + Changed {{ target_vars }} + Review the changes and update {{ target_sha1 }} + shell> sha1sum {{ target_vars }} > {{ target_sha1 }} + + - name: Changed tests.yml end host + ansible.builtin.meta: end_play + + - name: Test integrity RST file + when: + - integrity | d(true) | bool + - lookup('file', file_sha1) != lookup('pipe', 'sha1sum ' ~ file_rst) + block: + + - name: Changed RST file + ansible.builtin.debug: + msg: | + Changed {{ file_rst }} + Review the changes and update {{ file_sha1 }} + shell> sha1sum {{ file_rst }} > {{ file_sha1 }} + + - name: Changed RST file end host + ansible.builtin.meta: end_play + + - name: Include target vars + include_vars: + file: "{{ target_vars }}" + + - name: Create RST file + ansible.builtin.template: + src: "{{ file_base }}.rst.j2" + dest: "{{ file_base }}.rst" diff --git a/docs/docsite/helper/keep_keys/tests.yml.sha1 b/docs/docsite/helper/keep_keys/tests.yml.sha1 new file mode 100644 index 0000000000..fcf41a4347 --- /dev/null +++ b/docs/docsite/helper/keep_keys/tests.yml.sha1 @@ -0,0 +1 @@ +c6fc4ee2017d9222675bcd13cc4f88ba8d14f38d ../../../../tests/integration/targets/filter_keep_keys/vars/main/tests.yml diff --git a/docs/docsite/helper/keep_keys/tests.yml.sha1.license b/docs/docsite/helper/keep_keys/tests.yml.sha1.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/docs/docsite/helper/keep_keys/tests.yml.sha1.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/docs/docsite/helper/lists_mergeby/default-common.yml b/docs/docsite/helper/lists_mergeby/default-common.yml index fd874e5c91..4431fe27dc 100644 --- a/docs/docsite/helper/lists_mergeby/default-common.yml +++ b/docs/docsite/helper/lists_mergeby/default-common.yml @@ -2,17 +2,11 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - list1: - - name: foo - extra: true - - name: bar - extra: false - - name: meh - extra: true + - {name: foo, extra: true} + - {name: bar, extra: false} + - {name: meh, extra: true} list2: - - name: foo - path: /foo - - name: baz - path: /baz + - {name: foo, path: /foo} + - {name: baz, path: /baz} diff --git a/docs/docsite/helper/lists_mergeby/default-recursive-true.yml b/docs/docsite/helper/lists_mergeby/default-recursive-true.yml index 133c8f2aec..eb83ea82e1 100644 --- a/docs/docsite/helper/lists_mergeby/default-recursive-true.yml +++ b/docs/docsite/helper/lists_mergeby/default-recursive-true.yml @@ -2,14 +2,12 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - list1: - name: myname01 param01: x: default_value y: default_value - list: - - default_value + list: [default_value] - name: myname02 param01: [1, 1, 2, 3] @@ -18,7 +16,6 @@ list2: param01: y: patch_value z: patch_value - list: - - patch_value + list: [patch_value] - name: myname02 - param01: [3, 4, 4, {key: value}] + param01: [3, 4, 4] diff --git a/docs/docsite/helper/lists_mergeby/example-001.yml b/docs/docsite/helper/lists_mergeby/example-001.yml index 0cf6a9b8a7..c27b019e52 100644 --- a/docs/docsite/helper/lists_mergeby/example-001.yml +++ b/docs/docsite/helper/lists_mergeby/example-001.yml @@ -8,7 +8,7 @@ dir: example-001_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-001.out diff --git a/docs/docsite/helper/lists_mergeby/example-001_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-001_vars/list3.yml index 0604feccbd..8bd8bc8f24 100644 --- a/docs/docsite/helper/lists_mergeby/example-001_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-001_vars/list3.yml @@ -2,6 +2,5 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ list1| +list3: "{{ list1 | community.general.lists_mergeby(list2, 'name') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-002.yml b/docs/docsite/helper/lists_mergeby/example-002.yml index 5e6e0315df..e164db1251 100644 --- a/docs/docsite/helper/lists_mergeby/example-002.yml +++ b/docs/docsite/helper/lists_mergeby/example-002.yml @@ -8,7 +8,7 @@ dir: example-002_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-002.out diff --git a/docs/docsite/helper/lists_mergeby/example-002_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-002_vars/list3.yml index 8ad7524072..be6cfcbf31 100644 --- a/docs/docsite/helper/lists_mergeby/example-002_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-002_vars/list3.yml @@ -2,6 +2,5 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-003.yml b/docs/docsite/helper/lists_mergeby/example-003.yml index 2f93ab8a27..cbc5e43a50 100644 --- a/docs/docsite/helper/lists_mergeby/example-003.yml +++ b/docs/docsite/helper/lists_mergeby/example-003.yml @@ -8,7 +8,7 @@ dir: example-003_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-003.out diff --git a/docs/docsite/helper/lists_mergeby/example-003_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-003_vars/list3.yml index d5374eece5..2eff5df41a 100644 --- a/docs/docsite/helper/lists_mergeby/example-003_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-003_vars/list3.yml @@ -2,7 +2,6 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true) }}" diff --git a/docs/docsite/helper/lists_mergeby/example-004.yml b/docs/docsite/helper/lists_mergeby/example-004.yml index 3ef067faf3..68e77dea81 100644 --- a/docs/docsite/helper/lists_mergeby/example-004.yml +++ b/docs/docsite/helper/lists_mergeby/example-004.yml @@ -8,7 +8,7 @@ dir: example-004_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-004.out diff --git a/docs/docsite/helper/lists_mergeby/example-004_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-004_vars/list3.yml index a054ea1e73..94c8ceed38 100644 --- a/docs/docsite/helper/lists_mergeby/example-004_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-004_vars/list3.yml @@ -2,8 +2,7 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='keep') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-005.yml b/docs/docsite/helper/lists_mergeby/example-005.yml index 57e7a779d9..b7b81de294 100644 --- a/docs/docsite/helper/lists_mergeby/example-005.yml +++ b/docs/docsite/helper/lists_mergeby/example-005.yml @@ -8,7 +8,7 @@ dir: example-005_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-005.out diff --git a/docs/docsite/helper/lists_mergeby/example-005_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-005_vars/list3.yml index 3480bf6581..f0d7751f22 100644 --- a/docs/docsite/helper/lists_mergeby/example-005_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-005_vars/list3.yml @@ -2,8 +2,7 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='append') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-006.yml b/docs/docsite/helper/lists_mergeby/example-006.yml index 41fc88e496..1be3becbc0 100644 --- a/docs/docsite/helper/lists_mergeby/example-006.yml +++ b/docs/docsite/helper/lists_mergeby/example-006.yml @@ -8,7 +8,7 @@ dir: example-006_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-006.out diff --git a/docs/docsite/helper/lists_mergeby/example-006_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-006_vars/list3.yml index 97513b5593..f555c8dcb2 100644 --- a/docs/docsite/helper/lists_mergeby/example-006_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-006_vars/list3.yml @@ -2,8 +2,7 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='prepend') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-007.yml b/docs/docsite/helper/lists_mergeby/example-007.yml index 3de7158447..8a596ea68e 100644 --- a/docs/docsite/helper/lists_mergeby/example-007.yml +++ b/docs/docsite/helper/lists_mergeby/example-007.yml @@ -8,7 +8,7 @@ dir: example-007_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug|d(false) | bool - template: src: list3.out.j2 dest: example-007.out diff --git a/docs/docsite/helper/lists_mergeby/example-007_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-007_vars/list3.yml index cb51653b49..d8ad16cf4d 100644 --- a/docs/docsite/helper/lists_mergeby/example-007_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-007_vars/list3.yml @@ -2,8 +2,7 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='append_rp') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-008.yml b/docs/docsite/helper/lists_mergeby/example-008.yml index e33828bf9a..6d5c03bc6d 100644 --- a/docs/docsite/helper/lists_mergeby/example-008.yml +++ b/docs/docsite/helper/lists_mergeby/example-008.yml @@ -8,7 +8,7 @@ dir: example-008_vars - debug: var: list3 - when: debug|d(false)|bool + when: debug | d(false) | bool - template: src: list3.out.j2 dest: example-008.out diff --git a/docs/docsite/helper/lists_mergeby/example-008_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-008_vars/list3.yml index af7001fc4a..b2051376ea 100644 --- a/docs/docsite/helper/lists_mergeby/example-008_vars/list3.yml +++ b/docs/docsite/helper/lists_mergeby/example-008_vars/list3.yml @@ -2,8 +2,7 @@ # Copyright (c) Ansible Project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later - -list3: "{{ [list1, list2]| +list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='prepend_rp') }}" diff --git a/docs/docsite/helper/lists_mergeby/example-009.yml b/docs/docsite/helper/lists_mergeby/example-009.yml new file mode 100644 index 0000000000..beef5d356c --- /dev/null +++ b/docs/docsite/helper/lists_mergeby/example-009.yml @@ -0,0 +1,14 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: 9. Merge single list by common attribute 'name' + include_vars: + dir: example-009_vars +- debug: + var: list3 + when: debug | d(false) | bool +- template: + src: list3.out.j2 + dest: example-009.out diff --git a/docs/docsite/helper/lists_mergeby/example-009_vars/default-common.yml b/docs/docsite/helper/lists_mergeby/example-009_vars/default-common.yml new file mode 120000 index 0000000000..7ea8984a8d --- /dev/null +++ b/docs/docsite/helper/lists_mergeby/example-009_vars/default-common.yml @@ -0,0 +1 @@ +../default-common.yml \ No newline at end of file diff --git a/docs/docsite/helper/lists_mergeby/example-009_vars/list3.yml b/docs/docsite/helper/lists_mergeby/example-009_vars/list3.yml new file mode 100644 index 0000000000..1708e3bafa --- /dev/null +++ b/docs/docsite/helper/lists_mergeby/example-009_vars/list3.yml @@ -0,0 +1,6 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +list3: "{{ [list1 + list2, []] | + community.general.lists_mergeby('name') }}" diff --git a/docs/docsite/helper/lists_mergeby/examples.yml b/docs/docsite/helper/lists_mergeby/examples.yml index 83b985084e..34ad2d1558 100644 --- a/docs/docsite/helper/lists_mergeby/examples.yml +++ b/docs/docsite/helper/lists_mergeby/examples.yml @@ -4,51 +4,75 @@ # SPDX-License-Identifier: GPL-3.0-or-later examples: - - label: 'In the example below the lists are merged by the attribute ``name``:' + - title: Two lists + description: 'In the example below the lists are merged by the attribute ``name``:' file: example-001_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-001.out lang: 'yaml' - - label: 'It is possible to use a list of lists as an input of the filter:' + - title: List of two lists + description: 'It is possible to use a list of lists as an input of the filter:' file: example-002_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces the same result as in the previous example:' + - title: + description: 'This produces the same result as in the previous example:' file: example-002.out lang: 'yaml' - - label: 'Example ``list_merge=replace`` (default):' + - title: Single list + description: 'It is possible to merge single list:' + file: example-009_vars/list3.yml + lang: 'yaml+jinja' + - title: + description: 'This produces the same result as in the previous example:' + file: example-009.out + lang: 'yaml' + - title: list_merge=replace (default) + description: 'Example :ansopt:`community.general.lists_mergeby#filter:list_merge=replace` (default):' file: example-003_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-003.out lang: 'yaml' - - label: 'Example ``list_merge=keep``:' + - title: list_merge=keep + description: 'Example :ansopt:`community.general.lists_mergeby#filter:list_merge=keep`:' file: example-004_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-004.out lang: 'yaml' - - label: 'Example ``list_merge=append``:' + - title: list_merge=append + description: 'Example :ansopt:`community.general.lists_mergeby#filter:list_merge=append`:' file: example-005_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-005.out lang: 'yaml' - - label: 'Example ``list_merge=prepend``:' + - title: list_merge=prepend + description: 'Example :ansopt:`community.general.lists_mergeby#filter:list_merge=prepend`:' file: example-006_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-006.out lang: 'yaml' - - label: 'Example ``list_merge=append_rp``:' + - title: list_merge=append_rp + description: 'Example :ansopt:`community.general.lists_mergeby#filter:list_merge=append_rp`:' file: example-007_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-007.out lang: 'yaml' - - label: 'Example ``list_merge=prepend_rp``:' + - title: list_merge=prepend_rp + description: 'Example :ansopt:`community.general.lists_mergeby#filter:list_merge=prepend_rp`:' file: example-008_vars/list3.yml lang: 'yaml+jinja' - - label: 'This produces:' + - title: + description: 'This produces:' file: example-008.out lang: 'yaml' diff --git a/docs/docsite/helper/lists_mergeby/examples_all.rst.j2 b/docs/docsite/helper/lists_mergeby/examples_all.rst.j2 index 95a0fafddc..88098683b9 100644 --- a/docs/docsite/helper/lists_mergeby/examples_all.rst.j2 +++ b/docs/docsite/helper/lists_mergeby/examples_all.rst.j2 @@ -4,10 +4,10 @@ SPDX-License-Identifier: GPL-3.0-or-later {% for i in examples %} -{{ i.label }} +{{ i.description }} .. code-block:: {{ i.lang }} - {{ lookup('file', i.file)|indent(2) }} + {{ lookup('file', i.file) | split('\n') | reject('match', '^(#|---)') | join ('\n') | indent(2) }} {% endfor %} diff --git a/docs/docsite/helper/lists_mergeby/extra-vars.yml b/docs/docsite/helper/lists_mergeby/extra-vars.yml new file mode 100644 index 0000000000..0482c7ff29 --- /dev/null +++ b/docs/docsite/helper/lists_mergeby/extra-vars.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +examples_one: true +examples_all: true +merging_lists_of_dictionaries: true diff --git a/docs/docsite/helper/lists_mergeby/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst.j2 b/docs/docsite/helper/lists_mergeby/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst.j2 index 71d0d5da6c..ad74161dcd 100644 --- a/docs/docsite/helper/lists_mergeby/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst.j2 +++ b/docs/docsite/helper/lists_mergeby/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst.j2 @@ -6,57 +6,69 @@ Merging lists of dictionaries ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -If you have two or more lists of dictionaries and want to combine them into a list of merged dictionaries, where the dictionaries are merged by an attribute, you can use the ``lists_mergeby`` filter. +If you have two or more lists of dictionaries and want to combine them into a list of merged dictionaries, where the dictionaries are merged by an attribute, you can use the :ansplugin:`community.general.lists_mergeby ` filter. -.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See :ref:`the documentation for the community.general.yaml callback plugin `. +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See the documentation for the :ansplugin:`community.general.yaml callback plugin `. Let us use the lists below in the following examples: .. code-block:: yaml - {{ lookup('file', 'default-common.yml')|indent(2) }} + {{ lookup('file', 'default-common.yml') | split('\n') | reject('match', '^(#|---)') | join ('\n') | indent(2) }} {% for i in examples[0:2] %} -{{ i.label }} +{% if i.title | d('', true) | length > 0 %} +{{ i.title }} +{{ "%s" % ('"' * i.title|length) }} +{% endif %} +{{ i.description }} .. code-block:: {{ i.lang }} - {{ lookup('file', i.file)|indent(2) }} + {{ lookup('file', i.file) | split('\n') | reject('match', '^(#|---)') | join ('\n') | indent(2) }} {% endfor %} .. versionadded:: 2.0.0 -{% for i in examples[2:4] %} -{{ i.label }} +{% for i in examples[2:6] %} +{% if i.title | d('', true) | length > 0 %} +{{ i.title }} +{{ "%s" % ('"' * i.title|length) }} +{% endif %} +{{ i.description }} .. code-block:: {{ i.lang }} - {{ lookup('file', i.file)|indent(2) }} + {{ lookup('file', i.file) | split('\n') | reject('match', '^(#|---)') | join ('\n') | indent(2) }} {% endfor %} -The filter also accepts two optional parameters: ``recursive`` and ``list_merge``. These parameters are only supported when used with ansible-base 2.10 or ansible-core, but not with Ansible 2.9. This is available since community.general 4.4.0. +The filter also accepts two optional parameters: :ansopt:`community.general.lists_mergeby#filter:recursive` and :ansopt:`community.general.lists_mergeby#filter:list_merge`. This is available since community.general 4.4.0. **recursive** - Is a boolean, default to ``False``. Should the ``community.general.lists_mergeby`` recursively merge nested hashes. Note: It does not depend on the value of the ``hash_behaviour`` setting in ``ansible.cfg``. + Is a boolean, default to ``false``. Should the :ansplugin:`community.general.lists_mergeby#filter` filter recursively merge nested hashes. Note: It does not depend on the value of the ``hash_behaviour`` setting in ``ansible.cfg``. **list_merge** - Is a string, its possible values are ``replace`` (default), ``keep``, ``append``, ``prepend``, ``append_rp`` or ``prepend_rp``. It modifies the behaviour of ``community.general.lists_mergeby`` when the hashes to merge contain arrays/lists. + Is a string, its possible values are :ansval:`replace` (default), :ansval:`keep`, :ansval:`append`, :ansval:`prepend`, :ansval:`append_rp` or :ansval:`prepend_rp`. It modifies the behaviour of :ansplugin:`community.general.lists_mergeby#filter` when the hashes to merge contain arrays/lists. -The examples below set ``recursive=true`` and display the differences among all six options of ``list_merge``. Functionality of the parameters is exactly the same as in the filter ``combine``. See :ref:`Combining hashes/dictionaries ` to learn details about these options. +The examples below set :ansopt:`community.general.lists_mergeby#filter:recursive=true` and display the differences among all six options of :ansopt:`community.general.lists_mergeby#filter:list_merge`. Functionality of the parameters is exactly the same as in the filter :ansplugin:`ansible.builtin.combine#filter`. See :ref:`Combining hashes/dictionaries ` to learn details about these options. Let us use the lists below in the following examples .. code-block:: yaml - {{ lookup('file', 'default-recursive-true.yml')|indent(2) }} + {{ lookup('file', 'default-recursive-true.yml') | split('\n') | reject('match', '^(#|---)') | join ('\n') |indent(2) }} -{% for i in examples[4:16] %} -{{ i.label }} +{% for i in examples[6:] %} +{% if i.title | d('', true) | length > 0 %} +{{ i.title }} +{{ "%s" % ('"' * i.title|length) }} +{% endif %} +{{ i.description }} .. code-block:: {{ i.lang }} - {{ lookup('file', i.file)|indent(2) }} + {{ lookup('file', i.file) | split('\n') | reject('match', '^(#|---)') | join ('\n') |indent(2) }} {% endfor %} diff --git a/docs/docsite/helper/lists_mergeby/list3.out.j2 b/docs/docsite/helper/lists_mergeby/list3.out.j2 index b51f6b8681..a30a5c4ab0 100644 --- a/docs/docsite/helper/lists_mergeby/list3.out.j2 +++ b/docs/docsite/helper/lists_mergeby/list3.out.j2 @@ -4,4 +4,4 @@ GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://w SPDX-License-Identifier: GPL-3.0-or-later #} list3: -{{ list3|to_nice_yaml(indent=0) }} + {{ list3 | to_yaml(indent=2, sort_keys=false) | indent(2) }} diff --git a/docs/docsite/helper/lists_mergeby/playbook.yml b/docs/docsite/helper/lists_mergeby/playbook.yml index 793d233485..ab389fa129 100644 --- a/docs/docsite/helper/lists_mergeby/playbook.yml +++ b/docs/docsite/helper/lists_mergeby/playbook.yml @@ -5,7 +5,7 @@ # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 1) Run all examples and create example-XXX.out -# shell> ansible-playbook playbook.yml -e examples=true +# shell> ansible-playbook playbook.yml -e examples_one=true # # 2) Optionally, for testing, create examples_all.rst # shell> ansible-playbook playbook.yml -e examples_all=true @@ -45,18 +45,20 @@ tags: t007 - import_tasks: example-008.yml tags: t008 - when: examples|d(false)|bool + - import_tasks: example-009.yml + tags: t009 + when: examples_one | d(false) | bool - block: - include_vars: examples.yml - template: src: examples_all.rst.j2 dest: examples_all.rst - when: examples_all|d(false)|bool + when: examples_all | d(false) | bool - block: - include_vars: examples.yml - template: src: filter_guide_abstract_informations_merging_lists_of_dictionaries.rst.j2 dest: filter_guide_abstract_informations_merging_lists_of_dictionaries.rst - when: merging_lists_of_dictionaries|d(false)|bool + when: merging_lists_of_dictionaries | d(false) | bool diff --git a/docs/docsite/helper/remove_keys/README.md b/docs/docsite/helper/remove_keys/README.md new file mode 100644 index 0000000000..69a4076ef9 --- /dev/null +++ b/docs/docsite/helper/remove_keys/README.md @@ -0,0 +1,61 @@ + + +# Docs helper. Create RST file. + +The playbook `playbook.yml` writes a RST file that can be used in +docs/docsite/rst. The usage of this helper is recommended but not +mandatory. You can stop reading here and update the RST file manually +if you don't want to use this helper. + +## Run the playbook + +If you want to generate the RST file by this helper fit the variables +in the playbook and the template to your needs. Then, run the play + +```sh +shell> ansible-playbook playbook.yml +``` + +## Copy RST to docs/docsite/rst + +Copy the RST file to `docs/docsite/rst` and remove it from this +directory. + +## Update the checksums + +Substitute the variables and run the below commands + +```sh +shell> sha1sum {{ target_vars }} > {{ target_sha1 }} +shell> sha1sum {{ file_rst }} > {{ file_sha1 }} +``` + +## Playbook explained + +The playbook includes the variable *tests* from the integration tests +and creates the RST file from the template. The playbook will +terminate if: + +* The file with the variable *tests* was changed +* The RST file was changed + +This means that this helper is probably not up to date. + +### The file with the variable *tests* was changed + +This means that somebody updated the integration tests. Review the +changes and update the template if needed. Update the checksum to pass +the integrity test. The playbook message provides you with the +command. + +### The RST file was changed + +This means that somebody updated the RST file manually. Review the +changes and update the template. Update the checksum to pass the +integrity test. The playbook message provides you with the +command. Make sure that the updated template will create identical RST +file. Only then apply your changes. diff --git a/docs/docsite/helper/remove_keys/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst.j2 b/docs/docsite/helper/remove_keys/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst.j2 new file mode 100644 index 0000000000..62b25c344c --- /dev/null +++ b/docs/docsite/helper/remove_keys/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst.j2 @@ -0,0 +1,80 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +remove_keys +""""""""""" + +Use the filter :ansplugin:`community.general.remove_keys#filter` if you have a list of dictionaries and want to remove certain keys. + +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See See :ansplugin:`the documentation for the community.general.yaml callback plugin `. + + +Let us use the below list in the following examples: + +.. code-block:: yaml + + input: + {{ tests.0.input | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[0:1]|subelements('group') %} +* {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1 + + target: {{ i.1.tt }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.0.result | to_yaml(indent=2) | indent(5) }} + +.. versionadded:: 9.1.0 + +* The results of the below examples 1-5 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.1.result | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[1:2]|subelements('group') %} +{{ loop.index }}. {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: {{ i.1.mp }} + target: {{ i.1.tt }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +* The results of the below examples 6-9 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.2.result | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[2:3]|subelements('group') %} +{{ loop.index + 5 }}. {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: {{ i.1.mp }} + target: {{ i.1.tt }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} diff --git a/docs/docsite/helper/remove_keys/playbook.yml b/docs/docsite/helper/remove_keys/playbook.yml new file mode 100644 index 0000000000..a2243d992e --- /dev/null +++ b/docs/docsite/helper/remove_keys/playbook.yml @@ -0,0 +1,79 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Create docs REST files +# shell> ansible-playbook playbook.yml +# +# Proofread and copy created *.rst file into the directory +# docs/docsite/rst. Do not add *.rst in this directory to the version +# control. +# +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# community.general/docs/docsite/helper/remove_keys/playbook.yml + +- name: Create RST file for docs/docsite/rst + hosts: localhost + gather_facts: false + + vars: + + plugin: remove_keys + plugin_type: filter + docs_path: + - filter_guide + - abstract_informations + - lists_of_dictionaries + + file_base: "{{ (docs_path + [plugin]) | join('-') }}" + file_rst: ../../rst/{{ file_base }}.rst + file_sha1: "{{ plugin }}.rst.sha1" + + target: "../../../../tests/integration/targets/{{ plugin_type }}_{{ plugin }}" + target_vars: "{{ target }}/vars/main/tests.yml" + target_sha1: tests.yml.sha1 + + tasks: + + - name: Test integrity tests.yml + when: + - integrity | d(true) | bool + - lookup('file', target_sha1) != lookup('pipe', 'sha1sum ' ~ target_vars) + block: + + - name: Changed tests.yml + ansible.builtin.debug: + msg: | + Changed {{ target_vars }} + Review the changes and update {{ target_sha1 }} + shell> sha1sum {{ target_vars }} > {{ target_sha1 }} + + - name: Changed tests.yml end host + ansible.builtin.meta: end_play + + - name: Test integrity RST file + when: + - integrity | d(true) | bool + - lookup('file', file_sha1) != lookup('pipe', 'sha1sum ' ~ file_rst) + block: + + - name: Changed RST file + ansible.builtin.debug: + msg: | + Changed {{ file_rst }} + Review the changes and update {{ file_sha1 }} + shell> sha1sum {{ file_rst }} > {{ file_sha1 }} + + - name: Changed RST file end host + ansible.builtin.meta: end_play + + - name: Include target vars + include_vars: + file: "{{ target_vars }}" + + - name: Create RST file + ansible.builtin.template: + src: "{{ file_base }}.rst.j2" + dest: "{{ file_base }}.rst" diff --git a/docs/docsite/helper/remove_keys/remove_keys.rst.sha1 b/docs/docsite/helper/remove_keys/remove_keys.rst.sha1 new file mode 100644 index 0000000000..a1c9e18210 --- /dev/null +++ b/docs/docsite/helper/remove_keys/remove_keys.rst.sha1 @@ -0,0 +1 @@ +3cc606b42e3d450cf6323f25930f7c5a591fa086 ../../rst/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst diff --git a/docs/docsite/helper/remove_keys/remove_keys.rst.sha1.license b/docs/docsite/helper/remove_keys/remove_keys.rst.sha1.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/docs/docsite/helper/remove_keys/remove_keys.rst.sha1.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/docs/docsite/helper/remove_keys/tests.yml.sha1 b/docs/docsite/helper/remove_keys/tests.yml.sha1 new file mode 100644 index 0000000000..107a64d73c --- /dev/null +++ b/docs/docsite/helper/remove_keys/tests.yml.sha1 @@ -0,0 +1 @@ +0554335045f02d8c37b824355b0cf86864cee9a5 ../../../../tests/integration/targets/filter_remove_keys/vars/main/tests.yml diff --git a/docs/docsite/helper/remove_keys/tests.yml.sha1.license b/docs/docsite/helper/remove_keys/tests.yml.sha1.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/docs/docsite/helper/remove_keys/tests.yml.sha1.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/docs/docsite/helper/replace_keys/README.md b/docs/docsite/helper/replace_keys/README.md new file mode 100644 index 0000000000..69a4076ef9 --- /dev/null +++ b/docs/docsite/helper/replace_keys/README.md @@ -0,0 +1,61 @@ + + +# Docs helper. Create RST file. + +The playbook `playbook.yml` writes a RST file that can be used in +docs/docsite/rst. The usage of this helper is recommended but not +mandatory. You can stop reading here and update the RST file manually +if you don't want to use this helper. + +## Run the playbook + +If you want to generate the RST file by this helper fit the variables +in the playbook and the template to your needs. Then, run the play + +```sh +shell> ansible-playbook playbook.yml +``` + +## Copy RST to docs/docsite/rst + +Copy the RST file to `docs/docsite/rst` and remove it from this +directory. + +## Update the checksums + +Substitute the variables and run the below commands + +```sh +shell> sha1sum {{ target_vars }} > {{ target_sha1 }} +shell> sha1sum {{ file_rst }} > {{ file_sha1 }} +``` + +## Playbook explained + +The playbook includes the variable *tests* from the integration tests +and creates the RST file from the template. The playbook will +terminate if: + +* The file with the variable *tests* was changed +* The RST file was changed + +This means that this helper is probably not up to date. + +### The file with the variable *tests* was changed + +This means that somebody updated the integration tests. Review the +changes and update the template if needed. Update the checksum to pass +the integrity test. The playbook message provides you with the +command. + +### The RST file was changed + +This means that somebody updated the RST file manually. Review the +changes and update the template. Update the checksum to pass the +integrity test. The playbook message provides you with the +command. Make sure that the updated template will create identical RST +file. Only then apply your changes. diff --git a/docs/docsite/helper/replace_keys/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst.j2 b/docs/docsite/helper/replace_keys/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst.j2 new file mode 100644 index 0000000000..fb0af32f2f --- /dev/null +++ b/docs/docsite/helper/replace_keys/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst.j2 @@ -0,0 +1,110 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +replace_keys +"""""""""""" + +Use the filter :ansplugin:`community.general.replace_keys#filter` if you have a list of dictionaries and want to replace certain keys. + +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See :ansplugin:`the documentation for the community.general.yaml callback plugin `. + + +Let us use the below list in the following examples: + +.. code-block:: yaml + + input: + {{ tests.0.input | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[0:1]|subelements('group') %} +* {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1-3 + + target: + {{ i.1.tt | to_yaml(indent=2) | indent(5) }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.0.result | to_yaml(indent=2) | indent(5) }} + +.. versionadded:: 9.1.0 + +* The results of the below examples 1-3 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.1.result | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[1:2]|subelements('group') %} +{{ loop.index }}. {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1-4 + + mp: {{ i.1.mp }} + target: + {{ i.1.tt | to_yaml(indent=2) | indent(5) }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +* The results of the below examples 4-5 are the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ tests.2.result | to_yaml(indent=2) | indent(5) }} + +{% for i in tests[2:3]|subelements('group') %} +{{ loop.index + 3 }}. {{ i.1.d }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1-3 + + mp: {{ i.1.mp }} + target: + {{ i.1.tt | to_yaml(indent=2) | indent(5) }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +{% endfor %} + +{% for i in tests[3:4]|subelements('group') %} +{{ loop.index + 5 }}. {{ i.1.d }} + +.. code-block:: yaml + :emphasize-lines: 1- + + input: + {{ i.0.input | to_yaml(indent=2) | indent(5) }} + +.. code-block:: yaml+jinja + :emphasize-lines: 1-4 + + mp: {{ i.1.mp }} + target: + {{ i.1.tt | to_yaml(indent=2) | indent(5) }} + result: "{{ lookup('file', target ~ '/templates/' ~ i.0.template) }}" + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + {{ i.0.result | to_yaml(indent=2) | indent(5) }} + +{% endfor %} diff --git a/docs/docsite/helper/replace_keys/playbook.yml b/docs/docsite/helper/replace_keys/playbook.yml new file mode 100644 index 0000000000..3619000144 --- /dev/null +++ b/docs/docsite/helper/replace_keys/playbook.yml @@ -0,0 +1,79 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Create docs REST files +# shell> ansible-playbook playbook.yml +# +# Proofread and copy created *.rst file into the directory +# docs/docsite/rst. Do not add *.rst in this directory to the version +# control. +# +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# community.general/docs/docsite/helper/replace_keys/playbook.yml + +- name: Create RST file for docs/docsite/rst + hosts: localhost + gather_facts: false + + vars: + + plugin: replace_keys + plugin_type: filter + docs_path: + - filter_guide + - abstract_informations + - lists_of_dictionaries + + file_base: "{{ (docs_path + [plugin]) | join('-') }}" + file_rst: ../../rst/{{ file_base }}.rst + file_sha1: "{{ plugin }}.rst.sha1" + + target: "../../../../tests/integration/targets/{{ plugin_type }}_{{ plugin }}" + target_vars: "{{ target }}/vars/main/tests.yml" + target_sha1: tests.yml.sha1 + + tasks: + + - name: Test integrity tests.yml + when: + - integrity | d(true) | bool + - lookup('file', target_sha1) != lookup('pipe', 'sha1sum ' ~ target_vars) + block: + + - name: Changed tests.yml + ansible.builtin.debug: + msg: | + Changed {{ target_vars }} + Review the changes and update {{ target_sha1 }} + shell> sha1sum {{ target_vars }} > {{ target_sha1 }} + + - name: Changed tests.yml end host + ansible.builtin.meta: end_play + + - name: Test integrity RST file + when: + - integrity | d(true) | bool + - lookup('file', file_sha1) != lookup('pipe', 'sha1sum ' ~ file_rst) + block: + + - name: Changed RST file + ansible.builtin.debug: + msg: | + Changed {{ file_rst }} + Review the changes and update {{ file_sha1 }} + shell> sha1sum {{ file_rst }} > {{ file_sha1 }} + + - name: Changed RST file end host + ansible.builtin.meta: end_play + + - name: Include target vars + include_vars: + file: "{{ target_vars }}" + + - name: Create RST file + ansible.builtin.template: + src: "{{ file_base }}.rst.j2" + dest: "{{ file_base }}.rst" diff --git a/docs/docsite/helper/replace_keys/replace_keys.rst.sha1 b/docs/docsite/helper/replace_keys/replace_keys.rst.sha1 new file mode 100644 index 0000000000..2ae692f3cc --- /dev/null +++ b/docs/docsite/helper/replace_keys/replace_keys.rst.sha1 @@ -0,0 +1 @@ +403f23c02ac02b1c3b611cb14f9b3ba59dc3f587 ../../rst/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst diff --git a/docs/docsite/helper/replace_keys/replace_keys.rst.sha1.license b/docs/docsite/helper/replace_keys/replace_keys.rst.sha1.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/docs/docsite/helper/replace_keys/replace_keys.rst.sha1.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/docs/docsite/helper/replace_keys/tests.yml.sha1 b/docs/docsite/helper/replace_keys/tests.yml.sha1 new file mode 100644 index 0000000000..53944ddf74 --- /dev/null +++ b/docs/docsite/helper/replace_keys/tests.yml.sha1 @@ -0,0 +1 @@ +2e54f3528c95cca746d5748f1ed7ada56ad0890e ../../../../tests/integration/targets/filter_replace_keys/vars/main/tests.yml diff --git a/docs/docsite/helper/replace_keys/tests.yml.sha1.license b/docs/docsite/helper/replace_keys/tests.yml.sha1.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/docs/docsite/helper/replace_keys/tests.yml.sha1.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/docs/docsite/links.yml b/docs/docsite/links.yml index bd954c4096..fe41d1d2fd 100644 --- a/docs/docsite/links.yml +++ b/docs/docsite/links.yml @@ -9,6 +9,8 @@ edit_on_github: path_prefix: '' extra_links: + - description: Ask for help + url: https://forum.ansible.com/c/help/6/none - description: Submit a bug report url: https://github.com/ansible-collections/community.general/issues/new?assignees=&labels=&template=bug_report.yml - description: Request a feature @@ -22,6 +24,10 @@ communication: - topic: General usage and support questions network: Libera channel: '#ansible' - mailing_lists: - - topic: Ansible Project List - url: https://groups.google.com/g/ansible-project + forums: + - topic: "Ansible Forum: General usage and support questions" + # The following URL directly points to the "Get Help" section + url: https://forum.ansible.com/c/help/6/none + - topic: "Ansible Forum: Discussions about the collection itself, not for specific modules or plugins" + # The following URL directly points to the "community-general" tag + url: https://forum.ansible.com/tag/community-general diff --git a/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst new file mode 100644 index 0000000000..488cb2ce7d --- /dev/null +++ b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-keep_keys.rst @@ -0,0 +1,151 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +keep_keys +""""""""" + +Use the filter :ansplugin:`community.general.keep_keys#filter` if you have a list of dictionaries and want to keep certain keys only. + +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See :ansplugin:`the documentation for the community.general.yaml callback plugin `. + + +Let us use the below list in the following examples: + +.. code-block:: yaml + + input: + - k0_x0: A0 + k1_x1: B0 + k2_x2: [C0] + k3_x3: foo + - k0_x0: A1 + k1_x1: B1 + k2_x2: [C1] + k3_x3: bar + + +* By default, match keys that equal any of the items in the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1 + + target: ['k0_x0', 'k1_x1'] + result: "{{ input | community.general.keep_keys(target=target) }}" + + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - {k0_x0: A0, k1_x1: B0} + - {k0_x0: A1, k1_x1: B1} + + +.. versionadded:: 9.1.0 + +* The results of the below examples 1-5 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - {k0_x0: A0, k1_x1: B0} + - {k0_x0: A1, k1_x1: B1} + + +1. Match keys that equal any of the items in the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: equal + target: ['k0_x0', 'k1_x1'] + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +2. Match keys that start with any of the items in the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: starts_with + target: ['k0', 'k1'] + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +3. Match keys that end with any of the items in target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: ends_with + target: ['x0', 'x1'] + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +4. Match keys by the regex. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: regex + target: ['^.*[01]_x.*$'] + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +5. Match keys by the regex. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: regex + target: ^.*[01]_x.*$ + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + + +* The results of the below examples 6-9 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - {k0_x0: A0} + - {k0_x0: A1} + + +6. Match keys that equal the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: equal + target: k0_x0 + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +7. Match keys that start with the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: starts_with + target: k0 + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +8. Match keys that end with the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: ends_with + target: x0 + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + +9. Match keys by the regex. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: regex + target: ^.*0_x.*$ + result: "{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }}" + diff --git a/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst new file mode 100644 index 0000000000..03d4710f3a --- /dev/null +++ b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-remove_keys.rst @@ -0,0 +1,159 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +remove_keys +""""""""""" + +Use the filter :ansplugin:`community.general.remove_keys#filter` if you have a list of dictionaries and want to remove certain keys. + +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See See :ansplugin:`the documentation for the community.general.yaml callback plugin `. + + +Let us use the below list in the following examples: + +.. code-block:: yaml + + input: + - k0_x0: A0 + k1_x1: B0 + k2_x2: [C0] + k3_x3: foo + - k0_x0: A1 + k1_x1: B1 + k2_x2: [C1] + k3_x3: bar + + +* By default, match keys that equal any of the items in the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1 + + target: ['k0_x0', 'k1_x1'] + result: "{{ input | community.general.remove_keys(target=target) }}" + + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - k2_x2: [C0] + k3_x3: foo + - k2_x2: [C1] + k3_x3: bar + + +.. versionadded:: 9.1.0 + +* The results of the below examples 1-5 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - k2_x2: [C0] + k3_x3: foo + - k2_x2: [C1] + k3_x3: bar + + +1. Match keys that equal any of the items in the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: equal + target: ['k0_x0', 'k1_x1'] + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +2. Match keys that start with any of the items in the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: starts_with + target: ['k0', 'k1'] + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +3. Match keys that end with any of the items in target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: ends_with + target: ['x0', 'x1'] + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +4. Match keys by the regex. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: regex + target: ['^.*[01]_x.*$'] + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +5. Match keys by the regex. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: regex + target: ^.*[01]_x.*$ + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + + +* The results of the below examples 6-9 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - k1_x1: B0 + k2_x2: [C0] + k3_x3: foo + - k1_x1: B1 + k2_x2: [C1] + k3_x3: bar + + +6. Match keys that equal the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: equal + target: k0_x0 + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +7. Match keys that start with the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: starts_with + target: k0 + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +8. Match keys that end with the target. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: ends_with + target: x0 + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + +9. Match keys by the regex. + +.. code-block:: yaml+jinja + :emphasize-lines: 1,2 + + mp: regex + target: ^.*0_x.*$ + result: "{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }}" + diff --git a/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst new file mode 100644 index 0000000000..ba1bcad502 --- /dev/null +++ b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries-replace_keys.rst @@ -0,0 +1,175 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +replace_keys +"""""""""""" + +Use the filter :ansplugin:`community.general.replace_keys#filter` if you have a list of dictionaries and want to replace certain keys. + +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See :ansplugin:`the documentation for the community.general.yaml callback plugin `. + + +Let us use the below list in the following examples: + +.. code-block:: yaml + + input: + - k0_x0: A0 + k1_x1: B0 + k2_x2: [C0] + k3_x3: foo + - k0_x0: A1 + k1_x1: B1 + k2_x2: [C1] + k3_x3: bar + + +* By default, match keys that equal any of the attributes before. + +.. code-block:: yaml+jinja + :emphasize-lines: 1-3 + + target: + - {after: a0, before: k0_x0} + - {after: a1, before: k1_x1} + + result: "{{ input | community.general.replace_keys(target=target) }}" + + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - a0: A0 + a1: B0 + k2_x2: [C0] + k3_x3: foo + - a0: A1 + a1: B1 + k2_x2: [C1] + k3_x3: bar + + +.. versionadded:: 9.1.0 + +* The results of the below examples 1-3 are all the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - a0: A0 + a1: B0 + k2_x2: [C0] + k3_x3: foo + - a0: A1 + a1: B1 + k2_x2: [C1] + k3_x3: bar + + +1. Replace keys that starts with any of the attributes before. + +.. code-block:: yaml+jinja + :emphasize-lines: 1-4 + + mp: starts_with + target: + - {after: a0, before: k0} + - {after: a1, before: k1} + + result: "{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }}" + +2. Replace keys that ends with any of the attributes before. + +.. code-block:: yaml+jinja + :emphasize-lines: 1-4 + + mp: ends_with + target: + - {after: a0, before: x0} + - {after: a1, before: x1} + + result: "{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }}" + +3. Replace keys that match any regex of the attributes before. + +.. code-block:: yaml+jinja + :emphasize-lines: 1-4 + + mp: regex + target: + - {after: a0, before: ^.*0_x.*$} + - {after: a1, before: ^.*1_x.*$} + + result: "{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }}" + + +* The results of the below examples 4-5 are the same: + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - {X: foo} + - {X: bar} + + +4. If more keys match the same attribute before the last one will be used. + +.. code-block:: yaml+jinja + :emphasize-lines: 1-3 + + mp: regex + target: + - {after: X, before: ^.*_x.*$} + + result: "{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }}" + +5. If there are items with equal attribute before the first one will be used. + +.. code-block:: yaml+jinja + :emphasize-lines: 1-3 + + mp: regex + target: + - {after: X, before: ^.*_x.*$} + - {after: Y, before: ^.*_x.*$} + + result: "{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }}" + + +6. If there are more matches for a key the first one will be used. + +.. code-block:: yaml + :emphasize-lines: 1- + + input: + - {aaa1: A, bbb1: B, ccc1: C} + - {aaa2: D, bbb2: E, ccc2: F} + + +.. code-block:: yaml+jinja + :emphasize-lines: 1-4 + + mp: starts_with + target: + - {after: X, before: a} + - {after: Y, before: aa} + + result: "{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }}" + +gives + +.. code-block:: yaml + :emphasize-lines: 1- + + result: + - {X: A, bbb1: B, ccc1: C} + - {X: D, bbb2: E, ccc2: F} + + diff --git a/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries.rst b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries.rst new file mode 100644 index 0000000000..42737c44b7 --- /dev/null +++ b/docs/docsite/rst/filter_guide-abstract_informations-lists_of_dictionaries.rst @@ -0,0 +1,18 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.general.docsite.filter_guide.filter_guide_abstract_informations.lists_of_dicts: + +Lists of dictionaries +^^^^^^^^^^^^^^^^^^^^^ + +Filters to manage keys in a list of dictionaries: + +.. toctree:: + :maxdepth: 1 + + filter_guide-abstract_informations-lists_of_dictionaries-keep_keys + filter_guide-abstract_informations-lists_of_dictionaries-remove_keys + filter_guide-abstract_informations-lists_of_dictionaries-replace_keys diff --git a/docs/docsite/rst/filter_guide_abstract_informations.rst b/docs/docsite/rst/filter_guide_abstract_informations.rst index cac85089a0..818c09f02c 100644 --- a/docs/docsite/rst/filter_guide_abstract_informations.rst +++ b/docs/docsite/rst/filter_guide_abstract_informations.rst @@ -11,6 +11,7 @@ Abstract transformations filter_guide_abstract_informations_dictionaries filter_guide_abstract_informations_grouping + filter_guide-abstract_informations-lists_of_dictionaries filter_guide_abstract_informations_merging_lists_of_dictionaries filter_guide_abstract_informations_lists_helper filter_guide_abstract_informations_counting_elements_in_sequence diff --git a/docs/docsite/rst/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst b/docs/docsite/rst/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst index 06fa79d16a..cafe04e5c4 100644 --- a/docs/docsite/rst/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst +++ b/docs/docsite/rst/filter_guide_abstract_informations_merging_lists_of_dictionaries.rst @@ -6,33 +6,30 @@ Merging lists of dictionaries ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -If you have two or more lists of dictionaries and want to combine them into a list of merged dictionaries, where the dictionaries are merged by an attribute, you can use the :ansplugin:`community.general.lists_mergeby filter `. +If you have two or more lists of dictionaries and want to combine them into a list of merged dictionaries, where the dictionaries are merged by an attribute, you can use the :ansplugin:`community.general.lists_mergeby ` filter. -.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See :ref:`the documentation for the community.general.yaml callback plugin `. +.. note:: The output of the examples in this section use the YAML callback plugin. Quoting: "Ansible output that can be quite a bit easier to read than the default JSON formatting." See the documentation for the :ansplugin:`community.general.yaml callback plugin `. Let us use the lists below in the following examples: .. code-block:: yaml list1: - - name: foo - extra: true - - name: bar - extra: false - - name: meh - extra: true + - {name: foo, extra: true} + - {name: bar, extra: false} + - {name: meh, extra: true} list2: - - name: foo - path: /foo - - name: baz - path: /baz + - {name: foo, path: /foo} + - {name: baz, path: /baz} +Two lists +""""""""" In the example below the lists are merged by the attribute ``name``: .. code-block:: yaml+jinja - list3: "{{ list1| + list3: "{{ list1 | community.general.lists_mergeby(list2, 'name') }}" This produces: @@ -40,24 +37,21 @@ This produces: .. code-block:: yaml list3: - - extra: false - name: bar - - name: baz - path: /baz - - extra: true - name: foo - path: /foo - - extra: true - name: meh + - {name: bar, extra: false} + - {name: baz, path: /baz} + - {name: foo, extra: true, path: /foo} + - {name: meh, extra: true} .. versionadded:: 2.0.0 +List of two lists +""""""""""""""""" It is possible to use a list of lists as an input of the filter: .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name') }}" This produces the same result as in the previous example: @@ -65,15 +59,29 @@ This produces the same result as in the previous example: .. code-block:: yaml list3: - - extra: false - name: bar - - name: baz - path: /baz - - extra: true - name: foo - path: /foo - - extra: true - name: meh + - {name: bar, extra: false} + - {name: baz, path: /baz} + - {name: foo, extra: true, path: /foo} + - {name: meh, extra: true} + +Single list +""""""""""" +It is possible to merge single list: + +.. code-block:: yaml+jinja + + list3: "{{ [list1 + list2, []] | + community.general.lists_mergeby('name') }}" + +This produces the same result as in the previous example: + +.. code-block:: yaml + + list3: + - {name: bar, extra: false} + - {name: baz, path: /baz} + - {name: foo, extra: true, path: /foo} + - {name: meh, extra: true} The filter also accepts two optional parameters: :ansopt:`community.general.lists_mergeby#filter:recursive` and :ansopt:`community.general.lists_mergeby#filter:list_merge`. This is available since community.general 4.4.0. @@ -95,8 +103,7 @@ Let us use the lists below in the following examples param01: x: default_value y: default_value - list: - - default_value + list: [default_value] - name: myname02 param01: [1, 1, 2, 3] @@ -105,16 +112,17 @@ Let us use the lists below in the following examples param01: y: patch_value z: patch_value - list: - - patch_value + list: [patch_value] - name: myname02 - param01: [3, 4, 4, {key: value}] + param01: [3, 4, 4] +list_merge=replace (default) +"""""""""""""""""""""""""""" Example :ansopt:`community.general.lists_mergeby#filter:list_merge=replace` (default): .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true) }}" @@ -123,25 +131,22 @@ This produces: .. code-block:: yaml list3: - - name: myname01 - param01: - list: - - patch_value - x: default_value - y: patch_value - z: patch_value - - name: myname02 - param01: - - 3 - - 4 - - 4 - - key: value + - name: myname01 + param01: + x: default_value + y: patch_value + list: [patch_value] + z: patch_value + - name: myname02 + param01: [3, 4, 4] +list_merge=keep +""""""""""""""" Example :ansopt:`community.general.lists_mergeby#filter:list_merge=keep`: .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='keep') }}" @@ -151,25 +156,22 @@ This produces: .. code-block:: yaml list3: - - name: myname01 - param01: - list: - - default_value - x: default_value - y: patch_value - z: patch_value - - name: myname02 - param01: - - 1 - - 1 - - 2 - - 3 + - name: myname01 + param01: + x: default_value + y: patch_value + list: [default_value] + z: patch_value + - name: myname02 + param01: [1, 1, 2, 3] +list_merge=append +""""""""""""""""" Example :ansopt:`community.general.lists_mergeby#filter:list_merge=append`: .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='append') }}" @@ -179,30 +181,22 @@ This produces: .. code-block:: yaml list3: - - name: myname01 - param01: - list: - - default_value - - patch_value - x: default_value - y: patch_value - z: patch_value - - name: myname02 - param01: - - 1 - - 1 - - 2 - - 3 - - 3 - - 4 - - 4 - - key: value + - name: myname01 + param01: + x: default_value + y: patch_value + list: [default_value, patch_value] + z: patch_value + - name: myname02 + param01: [1, 1, 2, 3, 3, 4, 4] +list_merge=prepend +"""""""""""""""""" Example :ansopt:`community.general.lists_mergeby#filter:list_merge=prepend`: .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='prepend') }}" @@ -212,30 +206,22 @@ This produces: .. code-block:: yaml list3: - - name: myname01 - param01: - list: - - patch_value - - default_value - x: default_value - y: patch_value - z: patch_value - - name: myname02 - param01: - - 3 - - 4 - - 4 - - key: value - - 1 - - 1 - - 2 - - 3 + - name: myname01 + param01: + x: default_value + y: patch_value + list: [patch_value, default_value] + z: patch_value + - name: myname02 + param01: [3, 4, 4, 1, 1, 2, 3] +list_merge=append_rp +"""""""""""""""""""" Example :ansopt:`community.general.lists_mergeby#filter:list_merge=append_rp`: .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='append_rp') }}" @@ -245,29 +231,22 @@ This produces: .. code-block:: yaml list3: - - name: myname01 - param01: - list: - - default_value - - patch_value - x: default_value - y: patch_value - z: patch_value - - name: myname02 - param01: - - 1 - - 1 - - 2 - - 3 - - 4 - - 4 - - key: value + - name: myname01 + param01: + x: default_value + y: patch_value + list: [default_value, patch_value] + z: patch_value + - name: myname02 + param01: [1, 1, 2, 3, 4, 4] +list_merge=prepend_rp +""""""""""""""""""""" Example :ansopt:`community.general.lists_mergeby#filter:list_merge=prepend_rp`: .. code-block:: yaml+jinja - list3: "{{ [list1, list2]| + list3: "{{ [list1, list2] | community.general.lists_mergeby('name', recursive=true, list_merge='prepend_rp') }}" @@ -277,21 +256,12 @@ This produces: .. code-block:: yaml list3: - - name: myname01 - param01: - list: - - patch_value - - default_value - x: default_value - y: patch_value - z: patch_value - - name: myname02 - param01: - - 3 - - 4 - - 4 - - key: value - - 1 - - 1 - - 2 + - name: myname01 + param01: + x: default_value + y: patch_value + list: [patch_value, default_value] + z: patch_value + - name: myname02 + param01: [3, 4, 4, 1, 1, 2] diff --git a/docs/docsite/rst/guide_cmdrunner.rst b/docs/docsite/rst/guide_cmdrunner.rst new file mode 100644 index 0000000000..d4f12cf81e --- /dev/null +++ b/docs/docsite/rst/guide_cmdrunner.rst @@ -0,0 +1,463 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.general.docsite.guide_cmdrunner: + + +Command Runner guide +==================== + + +Introduction +^^^^^^^^^^^^ + +The ``ansible_collections.community.general.plugins.module_utils.cmd_runner`` module util provides the +``CmdRunner`` class to help execute external commands. The class is a wrapper around +the standard ``AnsibleModule.run_command()`` method, handling command arguments, localization setting, +output processing output, check mode, and other features. + +It is even more useful when one command is used in multiple modules, so that you can define all options +in a module util file, and each module uses the same runner with different arguments. + +For the sake of clarity, throughout this guide, unless otherwise specified, we use the term *option* when referring to +Ansible module options, and the term *argument* when referring to the command line arguments for the external command. + + +Quickstart +"""""""""" + +``CmdRunner`` defines a command and a set of coded instructions on how to format +the command-line arguments, in which specific order, for a particular execution. +It relies on ``ansible.module_utils.basic.AnsibleModule.run_command()`` to actually execute the command. +There are other features, see more details throughout this document. + +To use ``CmdRunner`` you must start by creating an object. The example below is a simplified +version of the actual code in :ansplugin:`community.general.ansible_galaxy_install#module`: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt + + runner = CmdRunner( + module, + command="ansible-galaxy", + arg_formats=dict( + type=cmd_runner_fmt.as_func(lambda v: [] if v == 'both' else [v]), + galaxy_cmd=cmd_runner_fmt.as_list(), + upgrade=cmd_runner_fmt.as_bool("--upgrade"), + requirements_file=cmd_runner_fmt.as_opt_val('-r'), + dest=cmd_runner_fmt.as_opt_val('-p'), + force=cmd_runner_fmt.as_bool("--force"), + no_deps=cmd_runner_fmt.as_bool("--no-deps"), + version=cmd_runner_fmt.as_fixed("--version"), + name=cmd_runner_fmt.as_list(), + ) + ) + +This is meant to be done once, then every time you need to execute the command you create a context and pass values as needed: + +.. code-block:: python + + # Run the command with these arguments, when values exist for them + with runner("type galaxy_cmd upgrade force no_deps dest requirements_file name", output_process=process) as ctx: + ctx.run(galaxy_cmd="install", upgrade=upgrade) + + # version is fixed, requires no value + with runner("version") as ctx: + dummy, stdout, dummy = ctx.run() + + # Another way of expressing it + dummy, stdout, dummy = runner("version").run() + +Note that you can pass values for the arguments when calling ``run()``, +otherwise ``CmdRunner`` uses the module options with the exact same names to +provide values for the runner arguments. If no value is passed and no module option +is found for the name specified, then an exception is raised, unless the +argument is using ``cmd_runner_fmt.as_fixed`` as format function like the +``version`` in the example above. See more about it below. + +In the first example, values of ``type``, ``force``, ``no_deps`` and others +are taken straight from the module, whilst ``galaxy_cmd`` and ``upgrade`` are +passed explicitly. + +That generates a resulting command line similar to (example taken from the +output of an integration test): + +.. code-block:: python + + [ + "/bin/ansible-galaxy", + "collection", + "install", + "--upgrade", + "-p", + "", + "netbox.netbox", + ] + + +Argument formats +^^^^^^^^^^^^^^^^ + +As seen in the example, ``CmdRunner`` expects a parameter named ``arg_formats`` +defining how to format each CLI named argument. +An "argument format" is nothing but a function to transform the value of a variable +into something formatted for the command line. + + +Argument format function +"""""""""""""""""""""""" + +An ``arg_format`` function should be of the form: + +.. code-block:: python + + def func(value): + return ["--some-param-name", value] + +The parameter ``value`` can be of any type - although there are convenience +mechanisms to help handling sequence and mapping objects. + +The result is expected to be of the type ``Sequence[str]`` type (most commonly +``list[str]`` or ``tuple[str]``), otherwise it is considered to be a ``str``, +and it is coerced into ``list[str]``. +This resulting sequence of strings is added to the command line when that +argument is actually used. + +For example, if ``func`` returns: + +- ``["nee", 2, "shruberries"]``, the command line adds arguments ``"nee" "2" "shruberries"``. +- ``2 == 2``, the command line adds argument ``True``. +- ``None``, the command line adds argument ``None``. +- ``[]``, the command line adds no command line argument for that particular argument. + + +Convenience format methods +"""""""""""""""""""""""""" + +In the same module as ``CmdRunner`` there is a class ``cmd_runner_fmt`` which +provides a set of convenience methods that return format functions for common cases. +In the first block of code in the `Quickstart`_ section you can see the importing of +that class: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt + +The same example shows how to make use of some of them in the instantiation of the ``CmdRunner`` object. +A description of each one of the convenience methods available and examples of how to use them is found below. +In these descriptions ``value`` refers to the single parameter passed to the formatting function. + +- ``cmd_runner_fmt.as_list()`` + This method does not receive any parameter, function returns ``value`` as-is. + + - Creation: + ``cmd_runner_fmt.as_list()`` + - Example: + +----------------------+---------------------+ + | Value | Outcome | + +======================+=====================+ + | ``["foo", "bar"]`` | ``["foo", "bar"]`` | + +----------------------+---------------------+ + | ``"foobar"`` | ``["foobar"]`` | + +----------------------+---------------------+ + +- ``cmd_runner_fmt.as_bool()`` + This method receives two different parameters: ``args_true`` and ``args_false``, latter being optional. + If the boolean evaluation of ``value`` is ``True``, the format function returns ``args_true``. + If the boolean evaluation is ``False``, then the function returns ``args_false`` + if it was provided, or ``[]`` otherwise. + + - Creation: + ``cmd_runner_fmt.as_bool("--force")`` + - Example: + +------------+--------------------+ + | Value | Outcome | + +============+====================+ + | ``True`` | ``["--force"]`` | + +------------+--------------------+ + | ``False`` | ``[]`` | + +------------+--------------------+ + +- ``cmd_runner_fmt.as_bool_not()`` + This method receives one parameter, which is returned by the function when the boolean evaluation + of ``value`` is ``False``. + + - Creation: + ``cmd_runner_fmt.as_bool_not("--no-deps")`` + - Example: + +-------------+---------------------+ + | Value | Outcome | + +=============+=====================+ + | ``True`` | ``[]`` | + +-------------+---------------------+ + | ``False`` | ``["--no-deps"]`` | + +-------------+---------------------+ + +- ``cmd_runner_fmt.as_optval()`` + This method receives one parameter ``arg``, the function returns the string concatenation + of ``arg`` and ``value``. + + - Creation: + ``cmd_runner_fmt.as_optval("-i")`` + - Example: + +---------------+---------------------+ + | Value | Outcome | + +===============+=====================+ + | ``3`` | ``["-i3"]`` | + +---------------+---------------------+ + | ``foobar`` | ``["-ifoobar"]`` | + +---------------+---------------------+ + +- ``cmd_runner_fmt.as_opt_val()`` + This method receives one parameter ``arg``, the function returns ``[arg, value]``. + + - Creation: + ``cmd_runner_fmt.as_opt_val("--name")`` + - Example: + +--------------+--------------------------+ + | Value | Outcome | + +==============+==========================+ + | ``abc`` | ``["--name", "abc"]`` | + +--------------+--------------------------+ + +- ``cmd_runner_fmt.as_opt_eq_val()`` + This method receives one parameter ``arg``, the function returns the string of the form + ``{arg}={value}``. + + - Creation: + ``cmd_runner_fmt.as_opt_eq_val("--num-cpus")`` + - Example: + +------------+-------------------------+ + | Value | Outcome | + +============+=========================+ + | ``10`` | ``["--num-cpus=10"]`` | + +------------+-------------------------+ + +- ``cmd_runner_fmt.as_fixed()`` + This method receives one parameter ``arg``, the function expects no ``value`` - if one + is provided then it is ignored. + The function returns ``arg`` as-is. + + - Creation: + ``cmd_runner_fmt.as_fixed("--version")`` + - Example: + +---------+-----------------------+ + | Value | Outcome | + +=========+=======================+ + | | ``["--version"]`` | + +---------+-----------------------+ + | 57 | ``["--version"]`` | + +---------+-----------------------+ + + - Note: + This is the only special case in which a value can be missing for the formatting function. + The example also comes from the code in `Quickstart`_. + In that case, the module has code to determine the command's version so that it can assert compatibility. + There is no *value* to be passed for that CLI argument. + +- ``cmd_runner_fmt.as_map()`` + This method receives one parameter ``arg`` which must be a dictionary, and an optional parameter ``default``. + The function returns the evaluation of ``arg[value]``. + If ``value not in arg``, then it returns ``default`` if defined, otherwise ``[]``. + + - Creation: + ``cmd_runner_fmt.as_map(dict(a=1, b=2, c=3), default=42)`` + - Example: + +---------------------+---------------+ + | Value | Outcome | + +=====================+===============+ + | ``"b"`` | ``["2"]`` | + +---------------------+---------------+ + | ``"yabadabadoo"`` | ``["42"]`` | + +---------------------+---------------+ + + - Note: + If ``default`` is not specified, invalid values return an empty list, meaning they are silently ignored. + +- ``cmd_runner_fmt.as_func()`` + This method receives one parameter ``arg`` which is itself is a format function and it must abide by the rules described above. + + - Creation: + ``cmd_runner_fmt.as_func(lambda v: [] if v == 'stable' else ['--channel', '{0}'.format(v)])`` + - Note: + The outcome for that depends entirely on the function provided by the developer. + + +Other features for argument formatting +"""""""""""""""""""""""""""""""""""""" + +Some additional features are available as decorators: + +- ``cmd_runner_fmt.unpack args()`` + This decorator unpacks the incoming ``value`` as a list of elements. + + For example, in ``ansible_collections.community.general.plugins.module_utils.puppet``, it is used as: + + .. code-block:: python + + @cmd_runner_fmt.unpack_args + def execute_func(execute, manifest): + if execute: + return ["--execute", execute] + else: + return [manifest] + + runner = CmdRunner( + module, + command=_prepare_base_cmd(), + path_prefix=_PUPPET_PATH_PREFIX, + arg_formats=dict( + # ... + _execute=cmd_runner_fmt.as_func(execute_func), + # ... + ), + ) + + Then, in :ansplugin:`community.general.puppet#module` it is put to use with: + + .. code-block:: python + + with runner(args_order) as ctx: + rc, stdout, stderr = ctx.run(_execute=[p['execute'], p['manifest']]) + +- ``cmd_runner_fmt.unpack_kwargs()`` + Conversely, this decorator unpacks the incoming ``value`` as a ``dict``-like object. + +- ``cmd_runner_fmt.stack()`` + This decorator assumes ``value`` is a sequence and concatenates the output + of the wrapped function applied to each element of the sequence. + + For example, in :ansplugin:`community.general.django_check#module`, the argument format for ``database`` + is defined as: + + .. code-block:: python + + arg_formats = dict( + # ... + database=cmd_runner_fmt.stack(cmd_runner_fmt.as_opt_val)("--database"), + # ... + ) + + When receiving a list ``["abc", "def"]``, the output is: + + .. code-block:: python + + ["--database", "abc", "--database", "def"] + + +Command Runner +^^^^^^^^^^^^^^ + +Settings that can be passed to the ``CmdRunner`` constructor are: + +- ``module: AnsibleModule`` + Module instance. Mandatory parameter. +- ``command: str | list[str]`` + Command to be executed. It can be a single string, the executable name, or a list + of strings containing the executable name as the first element and, optionally, fixed parameters. + Those parameters are used in all executions of the runner. +- ``arg_formats: dict`` + Mapping of argument names to formatting functions. +- ``default_args_order: str`` + As the name suggests, a default ordering for the arguments. When + this is passed, the context can be created without specifying ``args_order``. Defaults to ``()``. +- ``check_rc: bool`` + When ``True``, if the return code from the command is not zero, the module exits + with an error. Defaults to ``False``. +- ``path_prefix: list[str]`` + If the command being executed is installed in a non-standard directory path, + additional paths might be provided to search for the executable. Defaults to ``None``. +- ``environ_update: dict`` + Pass additional environment variables to be set during the command execution. + Defaults to ``None``. +- ``force_lang: str`` + It is usually important to force the locale to one specific value, so that responses are consistent and, therefore, parseable. + Please note that using this option (which is enabled by default) overwrites the environment variables ``LANGUAGE`` and ``LC_ALL``. + To disable this mechanism, set this parameter to ``None``. + In community.general 9.1.0 a special value ``auto`` was introduced for this parameter, with the effect + that ``CmdRunner`` then tries to determine the best parseable locale for the runtime. + It should become the default value in the future, but for the time being the default value is ``C``. + +When creating a context, the additional settings that can be passed to the call are: + +- ``args_order: str`` + Establishes the order in which the arguments are rendered in the command line. + This parameter is mandatory unless ``default_args_order`` was provided to the runner instance. +- ``output_process: func`` + Function to transform the output of the executable into different values or formats. + See examples in section below. +- ``check_mode_skip: bool`` + Whether to skip the actual execution of the command when the module is in check mode. + Defaults to ``False``. +- ``check_mode_return: any`` + If ``check_mode_skip=True``, then return this value instead. + +Additionally, any other valid parameters for ``AnsibleModule.run_command()`` may be passed, but unexpected behavior +might occur if redefining options already present in the runner or its context creation. Use with caution. + + +Processing results +^^^^^^^^^^^^^^^^^^ + +As mentioned, ``CmdRunner`` uses ``AnsibleModule.run_command()`` to execute the external command, +and it passes the return value from that method back to caller. That means that, +by default, the result is going to be a tuple ``(rc, stdout, stderr)``. + +If you need to transform or process that output, you can pass a function to the context, +as the ``output_process`` parameter. It must be a function like: + +.. code-block:: python + + def process(rc, stdout, stderr): + # do some magic + return processed_value # whatever that is + +In that case, the return of ``run()`` is the ``processed_value`` returned by the function. + + +PythonRunner +^^^^^^^^^^^^ + +The ``PythonRunner`` class is a specialized version of ``CmdRunner``, geared towards the execution of +Python scripts. It features two extra and mutually exclusive parameters ``python`` and ``venv`` in its constructor: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.python_runner import PythonRunner + from ansible_collections.community.general.plugins.module_utils.cmd_runner import cmd_runner_fmt + + runner = PythonRunner( + module, + command=["-m", "django"], + arg_formats=dict(...), + python="python", + venv="/path/to/some/venv", + ) + +The default value for ``python`` is the string ``python``, and the for ``venv`` it is ``None``. + +The command line produced by such a command with ``python="python3.12"`` is something like: + +.. code-block:: shell + + /usr/bin/python3.12 -m django ... + +And the command line for ``venv="/work/venv"`` is like: + +.. code-block:: shell + + /work/venv/bin/python -m django ... + +You may provide the value of the ``command`` argument as a string (in that case the string is used as a script name) +or as a list, in which case the elements of the list must be valid arguments for the Python interpreter, as in the example above. +See `Command line and environment `_ for more details. + +If the parameter ``python`` is an absolute path, or contains directory separators, such as ``/``, then it is used +as-is, otherwise the runtime ``PATH`` is searched for that command name. + +Other than that, everything else works as in ``CmdRunner``. + +.. versionadded:: 4.8.0 diff --git a/docs/docsite/rst/guide_deps.rst b/docs/docsite/rst/guide_deps.rst new file mode 100644 index 0000000000..4c0c4687a4 --- /dev/null +++ b/docs/docsite/rst/guide_deps.rst @@ -0,0 +1,74 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.general.docsite.guide_deps: + +``deps`` Guide +============== + + +Using ``deps`` +^^^^^^^^^^^^^^ + +The ``ansible_collections.community.general.plugins.module_utils.deps`` module util simplifies +the importing of code as described in :ref:`Importing and using shared code `. +Please notice that ``deps`` is meant to be used specifically with Ansible modules, and not other types of plugins. + +The same example from the Developer Guide would become: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils import deps + + with deps.declare("foo"): + import foo + +Then in ``main()``, just after the argspec (or anywhere in the code, for that matter), do + +.. code-block:: python + + deps.validate(module) # assuming module is a valid AnsibleModule instance + +By default, ``deps`` will rely on ``ansible.module_utils.basic.missing_required_lib`` to generate +a message about a failing import. That function accepts parameters ``reason`` and ``url``, and +and so does ``deps```: + +.. code-block:: python + + with deps.declare("foo", reason="foo is needed to properly bar", url="https://foo.bar.io"): + import foo + +If you would rather write a custom message instead of using ``missing_required_lib`` then do: + +.. code-block:: python + + with deps.declare("foo", msg="Custom msg explaining why foo is needed"): + import foo + +``deps`` allows for multiple dependencies to be declared: + +.. code-block:: python + + with deps.declare("foo"): + import foo + + with deps.declare("bar"): + import bar + + with deps.declare("doe"): + import doe + +By default, ``deps.validate()`` will check on all the declared dependencies, but if so desired, +they can be validated selectively by doing: + +.. code-block:: python + + deps.validate(module, "foo") # only validates the "foo" dependency + + deps.validate(module, "doe:bar") # only validates the "doe" and "bar" dependencies + + deps.validate(module, "-doe:bar") # validates all dependencies except "doe" and "bar" + +.. versionadded:: 6.1.0 diff --git a/docs/docsite/rst/guide_modulehelper.rst b/docs/docsite/rst/guide_modulehelper.rst new file mode 100644 index 0000000000..68b46e6c94 --- /dev/null +++ b/docs/docsite/rst/guide_modulehelper.rst @@ -0,0 +1,540 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.general.docsite.guide_modulehelper: + +Module Helper guide +=================== + + +Introduction +^^^^^^^^^^^^ + +Writing a module for Ansible is largely described in existing documentation. +However, a good part of that is boilerplate code that needs to be repeated every single time. +That is where ``ModuleHelper`` comes to assistance: a lot of that boilerplate code is done. + +.. _ansible_collections.community.general.docsite.guide_modulehelper.quickstart: + +Quickstart +"""""""""" + +See the `example from Ansible documentation `_ +written with ``ModuleHelper``. +But bear in mind that it does not showcase all of MH's features: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.module_helper import ModuleHelper + + + class MyTest(ModuleHelper): + module = dict( + argument_spec=dict( + name=dict(type='str', required=True), + new=dict(type='bool', required=False, default=False), + ), + supports_check_mode=True, + ) + use_old_vardict = False + + def __run__(self): + self.vars.original_message = '' + self.vars.message = '' + if self.check_mode: + return + self.vars.original_message = self.vars.name + self.vars.message = 'goodbye' + self.changed = self.vars['new'] + if self.vars.name == "fail me": + self.do_raise("You requested this to fail") + + + def main(): + MyTest.execute() + + + if __name__ == '__main__': + main() + + +Module Helper +^^^^^^^^^^^^^ + +Introduction +"""""""""""" + +``ModuleHelper`` is a wrapper around the standard ``AnsibleModule``, providing extra features and conveniences. +The basic structure of a module using ``ModuleHelper`` is as shown in the +:ref:`ansible_collections.community.general.docsite.guide_modulehelper.quickstart` +section above, but there are more elements that will take part in it. + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.module_helper import ModuleHelper + + class MyTest(ModuleHelper): + output_params = () + change_params = () + diff_params = () + facts_name = None + facts_params = () + use_old_vardict = True + mute_vardict_deprecation = False + module = dict( + argument_spec=dict(...), + # ... + ) + +After importing the ``ModuleHelper`` class, you need to declare your own class extending it. + +.. seealso:: + + There is a variation called ``StateModuleHelper``, which builds on top of the features provided by MH. + See :ref:`ansible_collections.community.general.docsite.guide_modulehelper.statemh` below for more details. + +The easiest way of specifying the module is to create the class variable ``module`` with a dictionary +containing the exact arguments that would be passed as parameters to ``AnsibleModule``. +If you prefer to create the ``AnsibleModule`` object yourself, just assign it to the ``module`` class variable. +MH also accepts a parameter ``module`` in its constructor, if that parameter is used used, +then it will override the class variable. The parameter can either be ``dict`` or ``AnsibleModule`` as well. + +Beyond the definition of the module, there are other variables that can be used to control aspects +of MH's behavior. These variables should be set at the very beginning of the class, and their semantics are +explained through this document. + +The main logic of MH happens in the ``ModuleHelper.run()`` method, which looks like: + +.. code-block:: python + + @module_fails_on_exception + def run(self): + self.__init_module__() + self.__run__() + self.__quit_module__() + output = self.output + if 'failed' not in output: + output['failed'] = False + self.module.exit_json(changed=self.has_changed(), **output) + +The method ``ModuleHelper.__run__()`` must be implemented by the module and most +modules will be able to perform their actions implementing only that MH method. +However, in some cases, you might want to execute actions before or after the main tasks, in which cases +you should implement ``ModuleHelper.__init_module__()`` and ``ModuleHelper.__quit_module__()`` respectively. + +Note that the output comes from ``self.output``, which is a ``@property`` method. +By default, that property will collect all the variables that are marked for output and return them in a dictionary with their values. +Moreover, the default ``self.output`` will also handle Ansible ``facts`` and *diff mode*. +Also note the changed status comes from ``self.has_changed()``, which is usually calculated from variables that are marked +to track changes in their content. + +.. seealso:: + + More details in sections + :ref:`ansible_collections.community.general.docsite.guide_modulehelper.paramvaroutput` and + :ref:`ansible_collections.community.general.docsite.guide_modulehelper.changes` below. + +.. seealso:: + + See more about the decorator + :ref:`ansible_collections.community.general.docsite.guide_modulehelper.modulefailsdeco` below. + + +Another way to write the example from the +:ref:`ansible_collections.community.general.docsite.guide_modulehelper.quickstart` +would be: + +.. code-block:: python + + def __init_module__(self): + self.vars.original_message = '' + self.vars.message = '' + + def __run__(self): + if self.check_mode: + return + self.vars.original_message = self.vars.name + self.vars.message = 'goodbye' + self.changed = self.vars['new'] + + def __quit_module__(self): + if self.vars.name == "fail me": + self.do_raise("You requested this to fail") + +Notice that there are no calls to ``module.exit_json()`` nor ``module.fail_json()``: if the module fails, raise an exception. +You can use the convenience method ``self.do_raise()`` or raise the exception as usual in Python to do that. +If no exception is raised, then the module succeeds. + +.. seealso:: + + See more about exceptions in section + :ref:`ansible_collections.community.general.docsite.guide_modulehelper.exceptions` below. + +Ansible modules must have a ``main()`` function and the usual test for ``'__main__'``. When using MH that should look like: + +.. code-block:: python + + def main(): + MyTest.execute() + + + if __name__ == '__main__': + main() + +The class method ``execute()`` is nothing more than a convenience shorcut for: + +.. code-block:: python + + m = MyTest() + m.run() + +Optionally, an ``AnsibleModule`` may be passed as parameter to ``execute()``. + +.. _ansible_collections.community.general.docsite.guide_modulehelper.paramvaroutput: + +Parameters, variables, and output +""""""""""""""""""""""""""""""""" + +All the parameters automatically become variables in the ``self.vars`` attribute, which is of the ``VarDict`` type. +By using ``self.vars``, you get a central mechanism to access the parameters but also to expose variables as return values of the module. +As described in :ref:`ansible_collections.community.general.docsite.guide_vardict`, variables in ``VarDict`` have metadata associated to them. +One of the attributes in that metadata marks the variable for output, and MH makes use of that to generate the module's return values. + +.. important:: + + The ``VarDict`` feature described was introduced in community.general 7.1.0, but there was a first + implementation of it embedded within ``ModuleHelper``. + That older implementation is now deprecated and will be removed in community.general 11.0.0. + After community.general 7.1.0, MH modules generate a deprecation message about *using the old VarDict*. + There are two ways to prevent that from happening: + + #. Set ``mute_vardict_deprecation = True`` and the deprecation will be silenced. If the module still uses the old ``VarDict``, + it will not be able to update to community.general 11.0.0 (Spring 2026) upon its release. + #. Set ``use_old_vardict = False`` to make the MH module use the new ``VarDict`` immediatelly. + The new ``VarDict`` and its use is documented and this is the recommended way to handle this. + + .. code-block:: python + + class MyTest(ModuleHelper): + use_old_vardict = False + mute_vardict_deprecation = True + ... + + These two settings are mutually exclusive, but that is not enforced and the behavior when setting both is not specified. + +Contrary to new variables created in ``VarDict``, module parameters are not set for output by default. +If you want to include some module parameters in the output, list them in the ``output_params`` class variable. + +.. code-block:: python + + class MyTest(ModuleHelper): + output_params = ('state', 'name') + ... + +Another neat feature provided by MH by using ``VarDict`` is the automatic tracking of changes when setting the metadata ``change=True``. +Again, to enable this feature for module parameters, you must list them in the ``change_params`` class variable. + +.. code-block:: python + + class MyTest(ModuleHelper): + # example from community.general.xfconf + change_params = ('value', ) + ... + +.. seealso:: + + See more about this in + :ref:`ansible_collections.community.general.docsite.guide_modulehelper.changes` below. + +Similarly, if you want to use Ansible's diff mode, you can set the metadata ``diff=True`` and ``diff_params`` for module parameters. +With that, MH will automatically generate the diff output for variables that have changed. + +.. code-block:: python + + class MyTest(ModuleHelper): + diff_params = ('value', ) + + def __run__(self): + # example from community.general.gio_mime + self.vars.set_meta("handler", initial_value=gio_mime_get(self.runner, self.vars.mime_type), diff=True, change=True) + +Moreover, if a module is set to return *facts* instead of return values, then again use the metadata ``fact=True`` and ``fact_params`` for module parameters. +Additionally, you must specify ``facts_name``, as in: + +.. code-block:: python + + class VolumeFacts(ModuleHelper): + facts_name = 'volume_facts' + + def __init_module__(self): + self.vars.set("volume", 123, fact=True) + +That generates an Ansible fact like: + +.. code-block:: yaml+jinja + + - name: Obtain volume facts + some.collection.volume_facts: + # parameters + + - name: Print volume facts + debug: + msg: Volume fact is {{ ansible_facts.volume_facts.volume }} + +.. important:: + + If ``facts_name`` is not set, the module does not generate any facts. + + +.. _ansible_collections.community.general.docsite.guide_modulehelper.changes: + +Handling changes +"""""""""""""""" + +In MH there are many ways to indicate change in the module execution. Here they are: + +Tracking changes in variables +----------------------------- + +As explained above, you can enable change tracking in any number of variables in ``self.vars``. +By the end of the module execution, if any of those variables has a value different then the first value assigned to them, +then that will be picked up by MH and signalled as changed at the module output. +See the example below to learn how you can enabled change tracking in variables: + +.. code-block:: python + + # using __init_module__() as example, it works the same in __run__() and __quit_module__() + def __init_module__(self): + # example from community.general.ansible_galaxy_install + self.vars.set("new_roles", {}, change=True) + + # example of "hidden" variable used only to track change in a value from community.general.gconftool2 + self.vars.set('_value', self.vars.previous_value, output=False, change=True) + + # enable change-tracking without assigning value + self.vars.set_meta("new_roles", change=True) + + # if you must forcibly set an initial value to the variable + self.vars.set_meta("new_roles", initial_value=[]) + ... + +If the end value of any variable marked ``change`` is different from its initial value, then MH will return ``changed=True``. + +Indicating changes with ``changed`` +----------------------------------- + +If you want to indicate change directly in the code, then use the ``self.changed`` property in MH. +Beware that this is a ``@property`` method in MH, with both a *getter* and a *setter*. +By default, that hidden field is set to ``False``. + +Effective change +---------------- + +The effective outcome for the module is determined in the ``self.has_changed()`` method, and it consists of the logical *OR* operation +between ``self.changed`` and the change calculated from ``self.vars``. + +.. _ansible_collections.community.general.docsite.guide_modulehelper.exceptions: + +Exceptions +"""""""""" + +In MH, instead of calling ``module.fail_json()`` you can just raise an exception. +The output variables are collected the same way they would be for a successful execution. +However, you can set output variables specifically for that exception, if you so choose. + +.. code-block:: python + + def __init_module__(self): + if not complex_validation(): + self.do_raise("Validation failed!") + + # Or passing output variables + awesomeness = calculate_awesomeness() + if awesomeness > 1000: + self.do_raise("Over awesome, I cannot handle it!", update_output={"awesomeness": awesomeness}) + +All exceptions derived from ``Exception`` are captured and translated into a ``fail_json()`` call. +However, if you do want to call ``self.module.fail_json()`` yourself it will work, +just keep in mind that there will be no automatic handling of output variables in that case. + +.. _ansible_collections.community.general.docsite.guide_modulehelper.statemh: + +StateModuleHelper +^^^^^^^^^^^^^^^^^ + +Many modules use a parameter ``state`` that effectively controls the exact action performed by the module, such as +``state=present`` or ``state=absent`` for installing or removing packages. +By using ``StateModuleHelper`` you can make your code like the excerpt from the ``gconftool2`` below: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.module_helper import StateModuleHelper + + class GConftool(StateModuleHelper): + ... + module = dict( + ... + ) + use_old_vardict = False + + def __init_module__(self): + self.runner = gconftool2_runner(self.module, check_rc=True) + ... + + self.vars.set('previous_value', self._get(), fact=True) + self.vars.set('value_type', self.vars.value_type) + self.vars.set('_value', self.vars.previous_value, output=False, change=True) + self.vars.set_meta('value', initial_value=self.vars.previous_value) + self.vars.set('playbook_value', self.vars.value, fact=True) + + ... + + def state_absent(self): + with self.runner("state key", output_process=self._make_process(False)) as ctx: + ctx.run() + self.vars.set('run_info', ctx.run_info, verbosity=4) + self.vars.set('new_value', None, fact=True) + self.vars._value = None + + def state_present(self): + with self.runner("direct config_source value_type state key value", output_process=self._make_process(True)) as ctx: + ctx.run() + self.vars.set('run_info', ctx.run_info, verbosity=4) + self.vars.set('new_value', self._get(), fact=True) + self.vars._value = self.vars.new_value + +Note that the method ``__run__()`` is implemented in ``StateModuleHelper``, all you need to implement are the methods ``state_``. +In the example above, :ansplugin:`community.general.gconftool2#module` only has two states, ``present`` and ``absent``, thus, ``state_present()`` and ``state_absent()``. + +If the controlling parameter is not called ``state``, like in :ansplugin:`community.general.jira#module` module, just let SMH know about it: + +.. code-block:: python + + class JIRA(StateModuleHelper): + state_param = 'operation' + + def operation_create(self): + ... + + def operation_search(self): + ... + +Lastly, if the module is called with ``state=somevalue`` and the method ``state_somevalue`` +is not implemented, SMH will resort to call a method called ``__state_fallback__()``. +By default, this method will raise a ``ValueError`` indicating the method was not found. +Naturally, you can override that method to write a default implementation, as in :ansplugin:`community.general.locale_gen#module`: + +.. code-block:: python + + def __state_fallback__(self): + if self.vars.state_tracking == self.vars.state: + return + if self.vars.ubuntu_mode: + self.apply_change_ubuntu(self.vars.state, self.vars.name) + else: + self.apply_change(self.vars.state, self.vars.name) + +That module has only the states ``present`` and ``absent`` and the code for both is the one in the fallback method. + +.. note:: + + The name of the fallback method **does not change** if you set a different value of ``state_param``. + + +Other Conveniences +^^^^^^^^^^^^^^^^^^ + +Delegations to AnsibleModule +"""""""""""""""""""""""""""" + +The MH properties and methods below are delegated as-is to the underlying ``AnsibleModule`` instance in ``self.module``: + +- ``check_mode`` +- ``get_bin_path()`` +- ``warn()`` +- ``deprecate()`` + +Additionally, MH will also delegate: + +- ``diff_mode`` to ``self.module._diff`` +- ``verbosity`` to ``self.module._verbosity`` + +Decorators +"""""""""" + +The following decorators should only be used within ``ModuleHelper`` class. + +@cause_changes +-------------- + +This decorator will control whether the outcome of the method will cause the module to signal change in its output. +If the method completes without raising an exception it is considered to have succeeded, otherwise, it will have failed. + +The decorator has a parameter ``when`` that accepts three different values: ``success``, ``failure``, and ``always``. +There are also two legacy parameters, ``on_success`` and ``on_failure``, that will be deprecated, so do not use them. +The value of ``changed`` in the module output will be set to ``True``: + +- ``when="success"`` and the method completes without raising an exception. +- ``when="failure"`` and the method raises an exception. +- ``when="always"``, regardless of the method raising an exception or not. + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.module_helper import cause_changes + + # adapted excerpt from the community.general.jira module + class JIRA(StateModuleHelper): + @cause_changes(when="success") + def operation_create(self): + ... + +If ``when`` has a different value or no parameters are specificied, the decorator will have no effect whatsoever. + +.. _ansible_collections.community.general.docsite.guide_modulehelper.modulefailsdeco: + +@module_fails_on_exception +-------------------------- + +In a method using this decorator, if an exception is raised, the text message of that exception will be captured +by the decorator and used to call ``self.module.fail_json()``. +In most of the cases there will be no need to use this decorator, because ``ModuleHelper.run()`` already uses it. + +@check_mode_skip +---------------- + +If the module is running in check mode, this decorator will prevent the method from executing. +The return value in that case is ``None``. + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.module_helper import check_mode_skip + + # adapted excerpt from the community.general.locale_gen module + class LocaleGen(StateModuleHelper): + @check_mode_skip + def __state_fallback__(self): + ... + + +@check_mode_skip_returns +------------------------ + +This decorator is similar to the previous one, but the developer can control the return value for the method when running in check mode. +It is used with one of two parameters. One is ``callable`` and the return value in check mode will be ``callable(self, *args, **kwargs)``, +where ``self`` is the ``ModuleHelper`` instance and the union of ``args`` and ``kwargs`` will contain all the parameters passed to the method. + +The other option is to use the parameter ``value``, in which case the method will return ``value`` when in check mode. + + +References +^^^^^^^^^^ + +- `Ansible Developer Guide `_ +- `Creating a module `_ +- `Returning ansible facts `_ +- :ref:`ansible_collections.community.general.docsite.guide_vardict` + + +.. versionadded:: 3.1.0 diff --git a/docs/docsite/rst/guide_vardict.rst b/docs/docsite/rst/guide_vardict.rst new file mode 100644 index 0000000000..f65b09055b --- /dev/null +++ b/docs/docsite/rst/guide_vardict.rst @@ -0,0 +1,176 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.general.docsite.guide_vardict: + +VarDict Guide +============= + +Introduction +^^^^^^^^^^^^ + +The ``ansible_collections.community.general.plugins.module_utils.vardict`` module util provides the +``VarDict`` class to help manage the module variables. That class is a container for module variables, +especially the ones for which the module must keep track of state changes, and the ones that should +be published as return values. + +Each variable has extra behaviors controlled by associated metadata, simplifying the generation of +output values from the module. + +Quickstart +"""""""""" + +The simplest way of using ``VarDict`` is: + +.. code-block:: python + + from ansible_collections.community.general.plugins.module_utils.vardict import VarDict + +Then in ``main()``, or any other function called from there: + +.. code-block:: python + + vars = VarDict() + + # Next 3 statements are equivalent + vars.abc = 123 + vars["abc"] = 123 + vars.set("abc", 123) + + vars.xyz = "bananas" + vars.ghi = False + +And by the time the module is about to exit: + +.. code-block:: python + + results = vars.output() + module.exit_json(**results) + +That makes the return value of the module: + +.. code-block:: javascript + + { + "abc": 123, + "xyz": "bananas", + "ghi": false + } + +Metadata +"""""""" + +The metadata values associated with each variable are: + +- ``output: bool`` - marks the variable for module output as a module return value. +- ``fact: bool`` - marks the variable for module output as an Ansible fact. +- ``verbosity: int`` - sets the minimum level of verbosity for which the variable will be included in the output. +- ``change: bool`` - controls the detection of changes in the variable value. +- ``initial_value: any`` - when using ``change`` and need to forcefully set an intial value to the variable. +- ``diff: bool`` - used along with ``change``, this generates an Ansible-style diff ``dict``. + +See the sections below for more details on how to use the metadata. + + +Using VarDict +^^^^^^^^^^^^^ + +Basic Usage +""""""""""" + +As shown above, variables can be accessed using the ``[]`` operator, as in a ``dict`` object, +and also as an object attribute, such as ``vars.abc``. The form using the ``set()`` +method is special in the sense that you can use it to set metadata values: + +.. code-block:: python + + vars.set("abc", 123, output=False) + vars.set("abc", 123, output=True, change=True) + +Another way to set metadata after the variables have been created is: + +.. code-block:: python + + vars.set_meta("abc", output=False) + vars.set_meta("abc", output=True, change=True, diff=True) + +You can use either operator and attribute forms to access the value of the variable. Other ways to +access its value and its metadata are: + +.. code-block:: python + + print("abc value = {0}".format(vars.var("abc")["value"])) # get the value + print("abc output? {0}".format(vars.get_meta("abc")["output"])) # get the metadata like this + +The names of methods, such as ``set``, ``get_meta``, ``output`` amongst others, are reserved and +cannot be used as variable names. If you try to use a reserved name a ``ValueError`` exception +is raised with the message "Name is reserved". + +Generating output +""""""""""""""""" + +By default, every variable create will be enable for output with minimum verbosity set to zero, in +other words, they will always be in the output by default. + +You can control that when creating the variable for the first time or later in the code: + +.. code-block:: python + + vars.set("internal", x + 4, output=False) + vars.set_meta("internal", output=False) + +You can also set the verbosity of some variable, like: + +.. code-block:: python + + vars.set("abc", x + 4) + vars.set("debug_x", x, verbosity=3) + + results = vars.output(module._verbosity) + module.exit_json(**results) + +If the module was invoked with verbosity lower than 3, then the output will only contain +the variable ``abc``. If running at higher verbosity, as in ``ansible-playbook -vvv``, +then the output will also contain ``debug_x``. + +Generating facts is very similar to regular output, but variables are not marked as facts by default. + +.. code-block:: python + + vars.set("modulefact", x + 4, fact=True) + vars.set("debugfact", x, fact=True, verbosity=3) + + results = vars.output(module._verbosity) + results["ansible_facts"] = {"module_name": vars.facts(module._verbosity)} + module.exit_json(**results) + +Handling change +""""""""""""""" + +You can use ``VarDict`` to determine whether variables have had their values changed. + +.. code-block:: python + + vars.set("abc", 42, change=True) + vars.abc = 90 + + results = vars.output() + results["changed"] = vars.has_changed + module.exit_json(**results) + +If tracking changes in variables, you may want to present the difference between the initial and the final +values of it. For that, you want to use: + +.. code-block:: python + + vars.set("abc", 42, change=True, diff=True) + vars.abc = 90 + + results = vars.output() + results["changed"] = vars.has_changed + results["diff"] = vars.diff() + module.exit_json(**results) + +.. versionadded:: 7.1.0 diff --git a/galaxy.yml b/galaxy.yml index 757e6c907f..5112bdc64f 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -5,7 +5,7 @@ namespace: community name: general -version: 8.6.0 +version: 9.5.0 readme: README.md authors: - Ansible (https://github.com/ansible) diff --git a/meta/runtime.yml b/meta/runtime.yml index 1dcd0878a5..4f5007b4a4 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -6,13 +6,54 @@ requires_ansible: '>=2.13.0' action_groups: consul: + - consul_agent_check + - consul_agent_service - consul_auth_method - consul_binding_rule - consul_policy - consul_role - consul_session - consul_token + proxmox: + - proxmox + - proxmox_disk + - proxmox_domain_info + - proxmox_group_info + - proxmox_kvm + - proxmox_nic + - proxmox_node_info + - proxmox_pool + - proxmox_pool_member + - proxmox_snap + - proxmox_storage_contents_info + - proxmox_storage_info + - proxmox_tasks_info + - proxmox_template + - proxmox_user_info + - proxmox_vm_info plugin_routing: + callback: + actionable: + tombstone: + removal_version: 2.0.0 + warning_text: Use the 'default' callback plugin with 'display_skipped_hosts + = no' and 'display_ok_hosts = no' options. + full_skip: + tombstone: + removal_version: 2.0.0 + warning_text: Use the 'default' callback plugin with 'display_skipped_hosts + = no' option. + hipchat: + deprecation: + removal_version: 10.0.0 + warning_text: The hipchat service has been discontinued and the self-hosted variant has been End of Life since 2020. + osx_say: + redirect: community.general.say + stderr: + tombstone: + removal_version: 2.0.0 + warning_text: Use the 'default' callback plugin with 'display_failed_stderr + = yes' option. connection: docker: redirect: community.docker.docker @@ -35,109 +76,109 @@ plugin_routing: removal_version: 10.0.0 warning_text: Use community.general.consul_token and/or community.general.consul_policy instead. rax_cbs_attachments: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_cbs: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_cdb_database: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_cdb_user: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_cdb: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_clb_nodes: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_clb_ssl: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_clb: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_dns_record: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_dns: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_facts: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_files_objects: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_files: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_identity: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_keypair: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_meta: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_mon_alarm: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_mon_check: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_mon_entity: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_mon_notification_plan: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_mon_notification: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_network: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_queue: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_scaling_group: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rax_scaling_policy: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on the deprecated package pyrax. + warning_text: This module relied on the deprecated package pyrax. rhn_channel: deprecation: removal_version: 10.0.0 @@ -149,9 +190,9 @@ plugin_routing: warning_text: RHN is EOL, please contact the community.general maintainers if still using this; see the module documentation for more details. stackdriver: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore, + warning_text: This module relied on HTTPS APIs that do not exist anymore, and any new development in the direction of providing an alternative should happen in the context of the google.cloud collection. ali_instance_facts: @@ -215,9 +256,9 @@ plugin_routing: docker_volume_info: redirect: community.docker.docker_volume_info flowdock: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore and + warning_text: This module relied on HTTPS APIs that do not exist anymore and there is no clear path to update. foreman: tombstone: @@ -705,29 +746,29 @@ plugin_routing: removal_version: 3.0.0 warning_text: Use community.general.vertica_info instead. webfaction_app: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore and + warning_text: This module relied on HTTPS APIs that do not exist anymore and there is no clear path to update. webfaction_db: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore and + warning_text: This module relied on HTTPS APIs that do not exist anymore and there is no clear path to update. webfaction_domain: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore and + warning_text: This module relied on HTTPS APIs that do not exist anymore and there is no clear path to update. webfaction_mailbox: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore and + warning_text: This module relied on HTTPS APIs that do not exist anymore and there is no clear path to update. webfaction_site: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module relies on HTTPS APIs that do not exist anymore and + warning_text: This module relied on HTTPS APIs that do not exist anymore and there is no clear path to update. xenserver_guest_facts: tombstone: @@ -735,9 +776,9 @@ plugin_routing: warning_text: Use community.general.xenserver_guest_info instead. doc_fragments: rackspace: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This doc fragment is used by rax modules, that rely on the deprecated + warning_text: This doc fragment was used by rax modules, that relied on the deprecated package pyrax. _gcp: redirect: community.google._gcp @@ -755,9 +796,9 @@ plugin_routing: redirect: community.postgresql.postgresql module_utils: rax: - deprecation: + tombstone: removal_version: 9.0.0 - warning_text: This module util relies on the deprecated package pyrax. + warning_text: This module util relied on the deprecated package pyrax. docker.common: redirect: community.docker.common docker.swarm: @@ -780,24 +821,6 @@ plugin_routing: redirect: dellemc.openmanage.dellemc_idrac remote_management.dellemc.ome: redirect: dellemc.openmanage.ome - callback: - actionable: - tombstone: - removal_version: 2.0.0 - warning_text: Use the 'default' callback plugin with 'display_skipped_hosts - = no' and 'display_ok_hosts = no' options. - full_skip: - tombstone: - removal_version: 2.0.0 - warning_text: Use the 'default' callback plugin with 'display_skipped_hosts - = no' option. - osx_say: - redirect: community.general.say - stderr: - tombstone: - removal_version: 2.0.0 - warning_text: Use the 'default' callback plugin with 'display_failed_stderr - = yes' option. inventory: docker_machine: redirect: community.docker.docker_machine diff --git a/plugins/action/iptables_state.py b/plugins/action/iptables_state.py index 4a27ef8a01..5ea55af58c 100644 --- a/plugins/action/iptables_state.py +++ b/plugins/action/iptables_state.py @@ -88,6 +88,10 @@ class ActionModule(ActionBase): max_timeout = self._connection._play_context.timeout module_args = self._task.args + async_status_args = {} + starter_cmd = None + confirm_cmd = None + if module_args.get('state', None) == 'restored': if not wrap_async: if not check_mode: diff --git a/plugins/become/doas.py b/plugins/become/doas.py index 69e730aad4..761e5e1e95 100644 --- a/plugins/become/doas.py +++ b/plugins/become/doas.py @@ -13,7 +13,8 @@ DOCUMENTATION = ''' author: Ansible Core Team options: become_user: - description: User you 'become' to execute the task + description: User you 'become' to execute the task. + type: string ini: - section: privilege_escalation key: become_user @@ -26,7 +27,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_USER - name: ANSIBLE_DOAS_USER become_exe: - description: Doas executable + description: Doas executable. + type: string default: doas ini: - section: privilege_escalation @@ -40,7 +42,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_DOAS_EXE become_flags: - description: Options to pass to doas + description: Options to pass to doas. + type: string default: '' ini: - section: privilege_escalation @@ -54,7 +57,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_DOAS_FLAGS become_pass: - description: password for doas prompt + description: Password for doas prompt. + type: string required: false vars: - name: ansible_become_password @@ -68,8 +72,10 @@ DOCUMENTATION = ''' key: password prompt_l10n: description: - - List of localized strings to match for prompt detection - - If empty we'll use the built in one + - List of localized strings to match for prompt detection. + - If empty we will use the built in one. + type: list + elements: string default: [] ini: - section: doas_become_plugin diff --git a/plugins/become/dzdo.py b/plugins/become/dzdo.py index a358e84e39..d94c684d1f 100644 --- a/plugins/become/dzdo.py +++ b/plugins/become/dzdo.py @@ -13,7 +13,8 @@ DOCUMENTATION = ''' author: Ansible Core Team options: become_user: - description: User you 'become' to execute the task + description: User you 'become' to execute the task. + type: string ini: - section: privilege_escalation key: become_user @@ -26,7 +27,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_USER - name: ANSIBLE_DZDO_USER become_exe: - description: Dzdo executable + description: Dzdo executable. + type: string default: dzdo ini: - section: privilege_escalation @@ -40,7 +42,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_DZDO_EXE become_flags: - description: Options to pass to dzdo + description: Options to pass to dzdo. + type: string default: -H -S -n ini: - section: privilege_escalation @@ -54,7 +57,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_DZDO_FLAGS become_pass: - description: Options to pass to dzdo + description: Options to pass to dzdo. + type: string required: false vars: - name: ansible_become_password diff --git a/plugins/become/ksu.py b/plugins/become/ksu.py index fa2f66864a..2be1832dc2 100644 --- a/plugins/become/ksu.py +++ b/plugins/become/ksu.py @@ -13,7 +13,8 @@ DOCUMENTATION = ''' author: Ansible Core Team options: become_user: - description: User you 'become' to execute the task + description: User you 'become' to execute the task. + type: string ini: - section: privilege_escalation key: become_user @@ -27,7 +28,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_KSU_USER required: true become_exe: - description: Su executable + description: Su executable. + type: string default: ksu ini: - section: privilege_escalation @@ -41,7 +43,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_KSU_EXE become_flags: - description: Options to pass to ksu + description: Options to pass to ksu. + type: string default: '' ini: - section: privilege_escalation @@ -55,7 +58,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_KSU_FLAGS become_pass: - description: ksu password + description: Ksu password. + type: string required: false vars: - name: ansible_ksu_pass @@ -69,8 +73,10 @@ DOCUMENTATION = ''' key: password prompt_l10n: description: - - List of localized strings to match for prompt detection - - If empty we'll use the built in one + - List of localized strings to match for prompt detection. + - If empty we will use the built in one. + type: list + elements: string default: [] ini: - section: ksu_become_plugin diff --git a/plugins/become/machinectl.py b/plugins/become/machinectl.py index 9b9ac7ec51..a0467c2c36 100644 --- a/plugins/become/machinectl.py +++ b/plugins/become/machinectl.py @@ -13,7 +13,8 @@ DOCUMENTATION = ''' author: Ansible Core Team options: become_user: - description: User you 'become' to execute the task + description: User you 'become' to execute the task. + type: string default: '' ini: - section: privilege_escalation @@ -27,7 +28,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_USER - name: ANSIBLE_MACHINECTL_USER become_exe: - description: Machinectl executable + description: Machinectl executable. + type: string default: machinectl ini: - section: privilege_escalation @@ -41,7 +43,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_MACHINECTL_EXE become_flags: - description: Options to pass to machinectl + description: Options to pass to machinectl. + type: string default: '' ini: - section: privilege_escalation @@ -55,7 +58,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_MACHINECTL_FLAGS become_pass: - description: Password for machinectl + description: Password for machinectl. + type: string required: false vars: - name: ansible_become_password @@ -78,12 +82,13 @@ DOCUMENTATION = ''' EXAMPLES = r''' # A polkit rule needed to use the module with a non-root user. # See the Notes section for details. -60-machinectl-fast-user-auth.rules: | - polkit.addRule(function(action, subject) { - if(action.id == "org.freedesktop.machine1.host-shell" && subject.isInGroup("wheel")) { - return polkit.Result.AUTH_SELF_KEEP; - } - }); +/etc/polkit-1/rules.d/60-machinectl-fast-user-auth.rules: | + polkit.addRule(function(action, subject) { + if(action.id == "org.freedesktop.machine1.host-shell" && + subject.isInGroup("wheel")) { + return polkit.Result.AUTH_SELF_KEEP; + } + }); ''' from re import compile as re_compile diff --git a/plugins/become/pbrun.py b/plugins/become/pbrun.py index 7d1437191e..8a96b75797 100644 --- a/plugins/become/pbrun.py +++ b/plugins/become/pbrun.py @@ -13,7 +13,8 @@ DOCUMENTATION = ''' author: Ansible Core Team options: become_user: - description: User you 'become' to execute the task + description: User you 'become' to execute the task. + type: string default: '' ini: - section: privilege_escalation @@ -27,7 +28,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_USER - name: ANSIBLE_PBRUN_USER become_exe: - description: Sudo executable + description: Sudo executable. + type: string default: pbrun ini: - section: privilege_escalation @@ -41,7 +43,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_PBRUN_EXE become_flags: - description: Options to pass to pbrun + description: Options to pass to pbrun. + type: string default: '' ini: - section: privilege_escalation @@ -55,7 +58,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_PBRUN_FLAGS become_pass: - description: Password for pbrun + description: Password for pbrun. + type: string required: false vars: - name: ansible_become_password @@ -68,7 +72,7 @@ DOCUMENTATION = ''' - section: pbrun_become_plugin key: password wrap_exe: - description: Toggle to wrap the command pbrun calls in 'shell -c' or not + description: Toggle to wrap the command pbrun calls in C(shell -c) or not. default: false type: bool ini: diff --git a/plugins/become/pfexec.py b/plugins/become/pfexec.py index 2468a28a94..d48d622713 100644 --- a/plugins/become/pfexec.py +++ b/plugins/become/pfexec.py @@ -14,9 +14,10 @@ DOCUMENTATION = ''' options: become_user: description: - - User you 'become' to execute the task + - User you 'become' to execute the task. - This plugin ignores this setting as pfexec uses it's own C(exec_attr) to figure this out, but it is supplied here for Ansible to make decisions needed for the task execution, like file permissions. + type: string default: root ini: - section: privilege_escalation @@ -30,7 +31,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_USER - name: ANSIBLE_PFEXEC_USER become_exe: - description: Sudo executable + description: Sudo executable. + type: string default: pfexec ini: - section: privilege_escalation @@ -44,7 +46,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_PFEXEC_EXE become_flags: - description: Options to pass to pfexec + description: Options to pass to pfexec. + type: string default: -H -S -n ini: - section: privilege_escalation @@ -58,7 +61,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_PFEXEC_FLAGS become_pass: - description: pfexec password + description: pfexec password. + type: string required: false vars: - name: ansible_become_password @@ -71,7 +75,7 @@ DOCUMENTATION = ''' - section: pfexec_become_plugin key: password wrap_exe: - description: Toggle to wrap the command pfexec calls in 'shell -c' or not + description: Toggle to wrap the command pfexec calls in C(shell -c) or not. default: false type: bool ini: @@ -82,7 +86,7 @@ DOCUMENTATION = ''' env: - name: ANSIBLE_PFEXEC_WRAP_EXECUTION notes: - - This plugin ignores O(become_user) as pfexec uses it's own C(exec_attr) to figure this out. + - This plugin ignores O(become_user) as pfexec uses its own C(exec_attr) to figure this out. ''' from ansible.plugins.become import BecomeBase diff --git a/plugins/become/pmrun.py b/plugins/become/pmrun.py index 74b633f09a..908c5e759d 100644 --- a/plugins/become/pmrun.py +++ b/plugins/become/pmrun.py @@ -14,6 +14,7 @@ DOCUMENTATION = ''' options: become_exe: description: Sudo executable + type: string default: pmrun ini: - section: privilege_escalation @@ -27,7 +28,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_PMRUN_EXE become_flags: - description: Options to pass to pmrun + description: Options to pass to pmrun. + type: string default: '' ini: - section: privilege_escalation @@ -41,7 +43,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_PMRUN_FLAGS become_pass: - description: pmrun password + description: pmrun password. + type: string required: false vars: - name: ansible_become_password diff --git a/plugins/become/run0.py b/plugins/become/run0.py new file mode 100644 index 0000000000..a718e86f24 --- /dev/null +++ b/plugins/become/run0.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ + name: run0 + short_description: Systemd's run0 + description: + - This become plugins allows your remote/login user to execute commands as another user via the C(run0) utility. + author: + - Thomas Sjögren (@konstruktoid) + version_added: '9.0.0' + options: + become_user: + description: User you 'become' to execute the task. + default: root + ini: + - section: privilege_escalation + key: become_user + - section: run0_become_plugin + key: user + vars: + - name: ansible_become_user + - name: ansible_run0_user + env: + - name: ANSIBLE_BECOME_USER + - name: ANSIBLE_RUN0_USER + type: string + become_exe: + description: The C(run0) executable. + default: run0 + ini: + - section: privilege_escalation + key: become_exe + - section: run0_become_plugin + key: executable + vars: + - name: ansible_become_exe + - name: ansible_run0_exe + env: + - name: ANSIBLE_BECOME_EXE + - name: ANSIBLE_RUN0_EXE + type: string + become_flags: + description: Options to pass to run0. + default: '' + ini: + - section: privilege_escalation + key: become_flags + - section: run0_become_plugin + key: flags + vars: + - name: ansible_become_flags + - name: ansible_run0_flags + env: + - name: ANSIBLE_BECOME_FLAGS + - name: ANSIBLE_RUN0_FLAGS + type: string + notes: + - This plugin will only work when a polkit rule is in place. +""" + +EXAMPLES = r""" +# An example polkit rule that allows the user 'ansible' in the 'wheel' group +# to execute commands using run0 without authentication. +/etc/polkit-1/rules.d/60-run0-fast-user-auth.rules: | + polkit.addRule(function(action, subject) { + if(action.id == "org.freedesktop.systemd1.manage-units" && + subject.isInGroup("wheel") && + subject.user == "ansible") { + return polkit.Result.YES; + } + }); +""" + +from re import compile as re_compile + +from ansible.plugins.become import BecomeBase +from ansible.module_utils._text import to_bytes + +ansi_color_codes = re_compile(to_bytes(r"\x1B\[[0-9;]+m")) + + +class BecomeModule(BecomeBase): + + name = "community.general.run0" + + prompt = "Password: " + fail = ("==== AUTHENTICATION FAILED ====",) + success = ("==== AUTHENTICATION COMPLETE ====",) + require_tty = ( + True # see https://github.com/ansible-collections/community.general/issues/6932 + ) + + @staticmethod + def remove_ansi_codes(line): + return ansi_color_codes.sub(b"", line) + + def build_become_command(self, cmd, shell): + super().build_become_command(cmd, shell) + + if not cmd: + return cmd + + become = self.get_option("become_exe") + flags = self.get_option("become_flags") + user = self.get_option("become_user") + + return ( + f"{become} --user={user} {flags} {self._build_success_command(cmd, shell)}" + ) + + def check_success(self, b_output): + b_output = self.remove_ansi_codes(b_output) + return super().check_success(b_output) + + def check_incorrect_password(self, b_output): + b_output = self.remove_ansi_codes(b_output) + return super().check_incorrect_password(b_output) + + def check_missing_password(self, b_output): + b_output = self.remove_ansi_codes(b_output) + return super().check_missing_password(b_output) diff --git a/plugins/become/sesu.py b/plugins/become/sesu.py index 5958c1bfca..4dcb837e70 100644 --- a/plugins/become/sesu.py +++ b/plugins/become/sesu.py @@ -13,7 +13,8 @@ DOCUMENTATION = ''' author: ansible (@nekonyuu) options: become_user: - description: User you 'become' to execute the task + description: User you 'become' to execute the task. + type: string default: '' ini: - section: privilege_escalation @@ -27,7 +28,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_USER - name: ANSIBLE_SESU_USER become_exe: - description: sesu executable + description: sesu executable. + type: string default: sesu ini: - section: privilege_escalation @@ -41,7 +43,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_EXE - name: ANSIBLE_SESU_EXE become_flags: - description: Options to pass to sesu + description: Options to pass to sesu. + type: string default: -H -S -n ini: - section: privilege_escalation @@ -55,7 +58,8 @@ DOCUMENTATION = ''' - name: ANSIBLE_BECOME_FLAGS - name: ANSIBLE_SESU_FLAGS become_pass: - description: Password to pass to sesu + description: Password to pass to sesu. + type: string required: false vars: - name: ansible_become_password diff --git a/plugins/become/sudosu.py b/plugins/become/sudosu.py index 60bb2aa517..5454fd2316 100644 --- a/plugins/become/sudosu.py +++ b/plugins/become/sudosu.py @@ -16,6 +16,7 @@ DOCUMENTATION = """ options: become_user: description: User you 'become' to execute the task. + type: string default: root ini: - section: privilege_escalation @@ -30,6 +31,7 @@ DOCUMENTATION = """ - name: ANSIBLE_SUDO_USER become_flags: description: Options to pass to C(sudo). + type: string default: -H -S -n ini: - section: privilege_escalation @@ -44,6 +46,7 @@ DOCUMENTATION = """ - name: ANSIBLE_SUDO_FLAGS become_pass: description: Password to pass to C(sudo). + type: string required: false vars: - name: ansible_become_password @@ -55,6 +58,21 @@ DOCUMENTATION = """ ini: - section: sudo_become_plugin key: password + alt_method: + description: + - Whether to use an alternative method to call C(su). Instead of running C(su -l user /path/to/shell -c command), + it runs C(su -l user -c command). + - Use this when the default one is not working on your system. + required: false + type: boolean + ini: + - section: community.general.sudosu + key: alternative_method + vars: + - name: ansible_sudosu_alt_method + env: + - name: ANSIBLE_SUDOSU_ALT_METHOD + version_added: 9.2.0 """ @@ -89,4 +107,7 @@ class BecomeModule(BecomeBase): if user: user = '%s' % (user) - return ' '.join([becomecmd, flags, prompt, 'su -l', user, self._build_success_command(cmd, shell)]) + if self.get_option('alt_method'): + return ' '.join([becomecmd, flags, prompt, "su -l", user, "-c", self._build_success_command(cmd, shell, True)]) + else: + return ' '.join([becomecmd, flags, prompt, 'su -l', user, self._build_success_command(cmd, shell)]) diff --git a/plugins/cache/memcached.py b/plugins/cache/memcached.py index 0bc5256b3f..93131172c5 100644 --- a/plugins/cache/memcached.py +++ b/plugins/cache/memcached.py @@ -29,6 +29,7 @@ DOCUMENTATION = ''' section: defaults _prefix: description: User defined prefix to use when creating the DB entries + type: string default: ansible_facts env: - name: ANSIBLE_CACHE_PLUGIN_PREFIX @@ -37,13 +38,14 @@ DOCUMENTATION = ''' section: defaults _timeout: default: 86400 + type: integer + # TODO: determine whether it is OK to change to: type: float description: Expiration timeout in seconds for the cache plugin data. Set to 0 to never expire env: - name: ANSIBLE_CACHE_PLUGIN_TIMEOUT ini: - key: fact_caching_timeout section: defaults - type: integer ''' import collections diff --git a/plugins/cache/pickle.py b/plugins/cache/pickle.py index 06b673921e..aeffa68939 100644 --- a/plugins/cache/pickle.py +++ b/plugins/cache/pickle.py @@ -24,6 +24,7 @@ DOCUMENTATION = ''' ini: - key: fact_caching_connection section: defaults + type: path _prefix: description: User defined prefix to use when creating the files env: @@ -31,6 +32,7 @@ DOCUMENTATION = ''' ini: - key: fact_caching_prefix section: defaults + type: string _timeout: default: 86400 description: Expiration timeout in seconds for the cache plugin data. Set to 0 to never expire @@ -39,6 +41,7 @@ DOCUMENTATION = ''' ini: - key: fact_caching_timeout section: defaults + type: float ''' try: diff --git a/plugins/cache/redis.py b/plugins/cache/redis.py index c43b1dbb5e..f96aafaa84 100644 --- a/plugins/cache/redis.py +++ b/plugins/cache/redis.py @@ -21,6 +21,7 @@ DOCUMENTATION = ''' - The format is V(host:port:db:password), for example V(localhost:6379:0:changeme). - To use encryption in transit, prefix the connection with V(tls://), as in V(tls://localhost:6379:0:changeme). - To use redis sentinel, use separator V(;), for example V(localhost:26379;localhost:26379;0:changeme). Requires redis>=2.9.0. + type: string required: true env: - name: ANSIBLE_CACHE_PLUGIN_CONNECTION @@ -29,6 +30,7 @@ DOCUMENTATION = ''' section: defaults _prefix: description: User defined prefix to use when creating the DB entries + type: string default: ansible_facts env: - name: ANSIBLE_CACHE_PLUGIN_PREFIX @@ -37,6 +39,7 @@ DOCUMENTATION = ''' section: defaults _keyset_name: description: User defined name for cache keyset name. + type: string default: ansible_cache_keys env: - name: ANSIBLE_CACHE_REDIS_KEYSET_NAME @@ -46,6 +49,7 @@ DOCUMENTATION = ''' version_added: 1.3.0 _sentinel_service_name: description: The redis sentinel service name (or referenced as cluster name). + type: string env: - name: ANSIBLE_CACHE_REDIS_SENTINEL ini: @@ -54,13 +58,14 @@ DOCUMENTATION = ''' version_added: 1.3.0 _timeout: default: 86400 + type: integer + # TODO: determine whether it is OK to change to: type: float description: Expiration timeout in seconds for the cache plugin data. Set to 0 to never expire env: - name: ANSIBLE_CACHE_PLUGIN_TIMEOUT ini: - key: fact_caching_timeout section: defaults - type: integer ''' import re @@ -222,7 +227,7 @@ class CacheModule(BaseCacheModule): def copy(self): # TODO: there is probably a better way to do this in redis - ret = dict([(k, self.get(k)) for k in self.keys()]) + ret = {k: self.get(k) for k in self.keys()} return ret def __getstate__(self): diff --git a/plugins/cache/yaml.py b/plugins/cache/yaml.py index 3a5ddf3e6f..a3d6f34521 100644 --- a/plugins/cache/yaml.py +++ b/plugins/cache/yaml.py @@ -24,6 +24,7 @@ DOCUMENTATION = ''' ini: - key: fact_caching_connection section: defaults + type: string _prefix: description: User defined prefix to use when creating the files env: @@ -31,6 +32,7 @@ DOCUMENTATION = ''' ini: - key: fact_caching_prefix section: defaults + type: string _timeout: default: 86400 description: Expiration timeout in seconds for the cache plugin data. Set to 0 to never expire @@ -40,6 +42,7 @@ DOCUMENTATION = ''' - key: fact_caching_timeout section: defaults type: integer + # TODO: determine whether it is OK to change to: type: float ''' diff --git a/plugins/callback/cgroup_memory_recap.py b/plugins/callback/cgroup_memory_recap.py index d3961bf0c8..643f0f0b88 100644 --- a/plugins/callback/cgroup_memory_recap.py +++ b/plugins/callback/cgroup_memory_recap.py @@ -25,6 +25,7 @@ DOCUMENTATION = ''' max_mem_file: required: true description: Path to cgroups C(memory.max_usage_in_bytes) file. Example V(/sys/fs/cgroup/memory/ansible_profile/memory.max_usage_in_bytes). + type: str env: - name: CGROUP_MAX_MEM_FILE ini: @@ -33,6 +34,7 @@ DOCUMENTATION = ''' cur_mem_file: required: true description: Path to C(memory.usage_in_bytes) file. Example V(/sys/fs/cgroup/memory/ansible_profile/memory.usage_in_bytes). + type: str env: - name: CGROUP_CUR_MEM_FILE ini: diff --git a/plugins/callback/hipchat.py b/plugins/callback/hipchat.py index 3e10b69e7f..bf0d425303 100644 --- a/plugins/callback/hipchat.py +++ b/plugins/callback/hipchat.py @@ -18,9 +18,14 @@ DOCUMENTATION = ''' description: - This callback plugin sends status updates to a HipChat channel during playbook execution. - Before 2.4 only environment variables were available for configuring this plugin. + deprecated: + removed_in: 10.0.0 + why: The hipchat service has been discontinued and the self-hosted variant has been End of Life since 2020. + alternative: There is none. options: token: description: HipChat API token for v1 or v2 API. + type: str required: true env: - name: HIPCHAT_TOKEN @@ -29,6 +34,10 @@ DOCUMENTATION = ''' key: token api_version: description: HipChat API version, v1 or v2. + type: str + choices: + - v1 + - v2 required: false default: v1 env: @@ -38,6 +47,7 @@ DOCUMENTATION = ''' key: api_version room: description: HipChat room to post in. + type: str default: ansible env: - name: HIPCHAT_ROOM @@ -46,6 +56,7 @@ DOCUMENTATION = ''' key: room from: description: Name to post as + type: str default: ansible env: - name: HIPCHAT_FROM diff --git a/plugins/callback/jabber.py b/plugins/callback/jabber.py index d2d00496d8..302687b708 100644 --- a/plugins/callback/jabber.py +++ b/plugins/callback/jabber.py @@ -20,21 +20,25 @@ DOCUMENTATION = ''' options: server: description: connection info to jabber server + type: str required: true env: - name: JABBER_SERV user: description: Jabber user to authenticate as + type: str required: true env: - name: JABBER_USER password: description: Password for the user to the jabber server + type: str required: true env: - name: JABBER_PASS to: description: chat identifier that will receive the message + type: str required: true env: - name: JABBER_TO diff --git a/plugins/callback/log_plays.py b/plugins/callback/log_plays.py index e99054e176..daa88bcc11 100644 --- a/plugins/callback/log_plays.py +++ b/plugins/callback/log_plays.py @@ -21,6 +21,7 @@ DOCUMENTATION = ''' log_folder: default: /var/log/ansible/hosts description: The folder where log files will be created. + type: str env: - name: ANSIBLE_LOG_FOLDER ini: diff --git a/plugins/callback/loganalytics.py b/plugins/callback/loganalytics.py index fbcdc6f89f..fd1b2772c4 100644 --- a/plugins/callback/loganalytics.py +++ b/plugins/callback/loganalytics.py @@ -21,6 +21,7 @@ DOCUMENTATION = ''' options: workspace_id: description: Workspace ID of the Azure log analytics workspace. + type: str required: true env: - name: WORKSPACE_ID @@ -29,6 +30,7 @@ DOCUMENTATION = ''' key: workspace_id shared_key: description: Shared key to connect to Azure log analytics workspace. + type: str required: true env: - name: WORKSPACE_SHARED_KEY @@ -59,13 +61,16 @@ import uuid import socket import getpass -from datetime import datetime from os.path import basename from ansible.module_utils.urls import open_url from ansible.parsing.ajson import AnsibleJSONEncoder from ansible.plugins.callback import CallbackBase +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + class AzureLogAnalyticsSource(object): def __init__(self): @@ -93,7 +98,7 @@ class AzureLogAnalyticsSource(object): return "https://{0}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01".format(workspace_id) def __rfc1123date(self): - return datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') + return now().strftime('%a, %d %b %Y %H:%M:%S GMT') def send_event(self, workspace_id, shared_key, state, result, runtime): if result._task_fields['args'].get('_ansible_check_mode') is True: @@ -167,7 +172,7 @@ class CallbackModule(CallbackBase): def _seconds_since_start(self, result): return ( - datetime.utcnow() - + now() - self.start_datetimes[result._task._uuid] ).total_seconds() @@ -185,10 +190,10 @@ class CallbackModule(CallbackBase): self.loganalytics.ansible_playbook = basename(playbook._file_name) def v2_playbook_on_task_start(self, task, is_conditional): - self.start_datetimes[task._uuid] = datetime.utcnow() + self.start_datetimes[task._uuid] = now() def v2_playbook_on_handler_task_start(self, task): - self.start_datetimes[task._uuid] = datetime.utcnow() + self.start_datetimes[task._uuid] = now() def v2_runner_on_ok(self, result, **kwargs): self.loganalytics.send_event( diff --git a/plugins/callback/logentries.py b/plugins/callback/logentries.py index d3feceb72e..c1271543ad 100644 --- a/plugins/callback/logentries.py +++ b/plugins/callback/logentries.py @@ -22,6 +22,7 @@ DOCUMENTATION = ''' options: api: description: URI to the Logentries API. + type: str env: - name: LOGENTRIES_API default: data.logentries.com @@ -30,6 +31,7 @@ DOCUMENTATION = ''' key: api port: description: HTTP port to use when connecting to the API. + type: int env: - name: LOGENTRIES_PORT default: 80 @@ -38,6 +40,7 @@ DOCUMENTATION = ''' key: port tls_port: description: Port to use when connecting to the API when TLS is enabled. + type: int env: - name: LOGENTRIES_TLS_PORT default: 443 @@ -46,6 +49,7 @@ DOCUMENTATION = ''' key: tls_port token: description: The logentries C(TCP token). + type: str env: - name: LOGENTRIES_ANSIBLE_TOKEN required: true diff --git a/plugins/callback/logstash.py b/plugins/callback/logstash.py index 144e1f9915..aa47ee4eb8 100644 --- a/plugins/callback/logstash.py +++ b/plugins/callback/logstash.py @@ -20,6 +20,7 @@ DOCUMENTATION = r''' options: server: description: Address of the Logstash server. + type: str env: - name: LOGSTASH_SERVER ini: @@ -29,6 +30,7 @@ DOCUMENTATION = r''' default: localhost port: description: Port on which logstash is listening. + type: int env: - name: LOGSTASH_PORT ini: @@ -38,6 +40,7 @@ DOCUMENTATION = r''' default: 5000 type: description: Message type. + type: str env: - name: LOGSTASH_TYPE ini: @@ -47,6 +50,7 @@ DOCUMENTATION = r''' default: ansible pre_command: description: Executes command before run and its result is added to the C(ansible_pre_command_output) logstash field. + type: str version_added: 2.0.0 ini: - section: callback_logstash @@ -99,7 +103,6 @@ from ansible import context import socket import uuid import logging -from datetime import datetime try: import logstash @@ -109,6 +112,10 @@ except ImportError: from ansible.plugins.callback import CallbackBase +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + class CallbackModule(CallbackBase): @@ -126,7 +133,7 @@ class CallbackModule(CallbackBase): "pip install python-logstash for Python 2" "pip install python3-logstash for Python 3") - self.start_time = datetime.utcnow() + self.start_time = now() def _init_plugin(self): if not self.disabled: @@ -185,7 +192,7 @@ class CallbackModule(CallbackBase): self.logger.info("ansible start", extra=data) def v2_playbook_on_stats(self, stats): - end_time = datetime.utcnow() + end_time = now() runtime = end_time - self.start_time summarize_stat = {} for host in stats.processed.keys(): diff --git a/plugins/callback/opentelemetry.py b/plugins/callback/opentelemetry.py index 492e420716..8dc627c214 100644 --- a/plugins/callback/opentelemetry.py +++ b/plugins/callback/opentelemetry.py @@ -84,6 +84,32 @@ DOCUMENTATION = ''' - section: callback_opentelemetry key: disable_attributes_in_logs version_added: 7.1.0 + store_spans_in_file: + type: str + description: + - It stores the exported spans in the given file + env: + - name: ANSIBLE_OPENTELEMETRY_STORE_SPANS_IN_FILE + ini: + - section: callback_opentelemetry + key: store_spans_in_file + version_added: 9.0.0 + otel_exporter_otlp_traces_protocol: + type: str + description: + - E(OTEL_EXPORTER_OTLP_TRACES_PROTOCOL) represents the the transport protocol for spans. + - See + U(https://opentelemetry-python.readthedocs.io/en/latest/sdk/environment_variables.html#envvar-OTEL_EXPORTER_OTLP_TRACES_PROTOCOL). + default: grpc + choices: + - grpc + - http/protobuf + env: + - name: OTEL_EXPORTER_OTLP_TRACES_PROTOCOL + ini: + - section: callback_opentelemetry + key: otel_exporter_otlp_traces_protocol + version_added: 9.0.0 requirements: - opentelemetry-api (Python library) - opentelemetry-exporter-otlp (Python library) @@ -107,6 +133,7 @@ examples: | ''' import getpass +import json import os import socket import sys @@ -124,15 +151,19 @@ from ansible.plugins.callback import CallbackBase try: from opentelemetry import trace from opentelemetry.trace import SpanKind - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCOTLPSpanExporter + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HTTPOTLPSpanExporter from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.trace.status import Status, StatusCode from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import ( - BatchSpanProcessor + BatchSpanProcessor, + SimpleSpanProcessor + ) + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter ) - # Support for opentelemetry-api <= 1.12 try: from opentelemetry.util._time import _time_ns @@ -255,7 +286,16 @@ class OpenTelemetrySource(object): task.dump = dump task.add_host(HostData(host_uuid, host_name, status, result)) - def generate_distributed_traces(self, otel_service_name, ansible_playbook, tasks_data, status, traceparent, disable_logs, disable_attributes_in_logs): + def generate_distributed_traces(self, + otel_service_name, + ansible_playbook, + tasks_data, + status, + traceparent, + disable_logs, + disable_attributes_in_logs, + otel_exporter_otlp_traces_protocol, + store_spans_in_file): """ generate distributed traces from the collected TaskData and HostData """ tasks = [] @@ -271,7 +311,16 @@ class OpenTelemetrySource(object): ) ) - processor = BatchSpanProcessor(OTLPSpanExporter()) + otel_exporter = None + if store_spans_in_file: + otel_exporter = InMemorySpanExporter() + processor = SimpleSpanProcessor(otel_exporter) + else: + if otel_exporter_otlp_traces_protocol == 'grpc': + otel_exporter = GRPCOTLPSpanExporter() + else: + otel_exporter = HTTPOTLPSpanExporter() + processor = BatchSpanProcessor(otel_exporter) trace.get_tracer_provider().add_span_processor(processor) @@ -293,6 +342,8 @@ class OpenTelemetrySource(object): with tracer.start_as_current_span(task.name, start_time=task.start, end_on_exit=False) as span: self.update_span_data(task, host_data, span, disable_logs, disable_attributes_in_logs) + return otel_exporter + def update_span_data(self, task_data, host_data, span, disable_logs, disable_attributes_in_logs): """ update the span with the given TaskData and HostData """ @@ -304,6 +355,7 @@ class OpenTelemetrySource(object): status = Status(status_code=StatusCode.OK) if host_data.status != 'included': # Support loops + enriched_error_message = None if 'results' in host_data.result._result: if host_data.status == 'failed': message = self.get_error_message_from_results(host_data.result._result['results'], task_data.action) @@ -350,7 +402,8 @@ class OpenTelemetrySource(object): if not disable_logs: # This will avoid populating span attributes to the logs span.add_event(task_data.dump, attributes={} if disable_attributes_in_logs else attributes) - span.end(end_time=host_data.finish) + # Close span always + span.end(end_time=host_data.finish) def set_span_attributes(self, span, attributes): """ update the span attributes with the given attributes if not None """ @@ -462,6 +515,8 @@ class CallbackModule(CallbackBase): self.errors = 0 self.disabled = False self.traceparent = False + self.store_spans_in_file = False + self.otel_exporter_otlp_traces_protocol = None if OTEL_LIBRARY_IMPORT_ERROR: raise_from( @@ -489,6 +544,8 @@ class CallbackModule(CallbackBase): self.disable_logs = self.get_option('disable_logs') + self.store_spans_in_file = self.get_option('store_spans_in_file') + self.otel_service_name = self.get_option('otel_service_name') if not self.otel_service_name: @@ -497,6 +554,22 @@ class CallbackModule(CallbackBase): # See https://github.com/open-telemetry/opentelemetry-specification/issues/740 self.traceparent = self.get_option('traceparent') + self.otel_exporter_otlp_traces_protocol = self.get_option('otel_exporter_otlp_traces_protocol') + + def dump_results(self, task, result): + """ dump the results if disable_logs is not enabled """ + if self.disable_logs: + return "" + # ansible.builtin.uri contains the response in the json field + save = dict(result._result) + + if "json" in save and task.action in ("ansible.builtin.uri", "ansible.legacy.uri", "uri"): + save.pop("json") + # ansible.builtin.slurp contains the response in the content field + if "content" in save and task.action in ("ansible.builtin.slurp", "ansible.legacy.slurp", "slurp"): + save.pop("content") + return self._dump_results(save) + def v2_playbook_on_start(self, playbook): self.ansible_playbook = basename(playbook._file_name) @@ -546,7 +619,7 @@ class CallbackModule(CallbackBase): self.tasks_data, status, result, - self._dump_results(result._result) + self.dump_results(self.tasks_data[result._task._uuid], result) ) def v2_runner_on_ok(self, result): @@ -554,7 +627,7 @@ class CallbackModule(CallbackBase): self.tasks_data, 'ok', result, - self._dump_results(result._result) + self.dump_results(self.tasks_data[result._task._uuid], result) ) def v2_runner_on_skipped(self, result): @@ -562,7 +635,7 @@ class CallbackModule(CallbackBase): self.tasks_data, 'skipped', result, - self._dump_results(result._result) + self.dump_results(self.tasks_data[result._task._uuid], result) ) def v2_playbook_on_include(self, included_file): @@ -578,15 +651,22 @@ class CallbackModule(CallbackBase): status = Status(status_code=StatusCode.OK) else: status = Status(status_code=StatusCode.ERROR) - self.opentelemetry.generate_distributed_traces( + otel_exporter = self.opentelemetry.generate_distributed_traces( self.otel_service_name, self.ansible_playbook, self.tasks_data, status, self.traceparent, self.disable_logs, - self.disable_attributes_in_logs + self.disable_attributes_in_logs, + self.otel_exporter_otlp_traces_protocol, + self.store_spans_in_file ) + if self.store_spans_in_file: + spans = [json.loads(span.to_json()) for span in otel_exporter.get_finished_spans()] + with open(self.store_spans_in_file, "w", encoding="utf-8") as output: + json.dump({"spans": spans}, output, indent=4) + def v2_runner_on_async_failed(self, result, **kwargs): self.errors += 1 diff --git a/plugins/callback/slack.py b/plugins/callback/slack.py index e7a2743ec5..2a995992ee 100644 --- a/plugins/callback/slack.py +++ b/plugins/callback/slack.py @@ -22,6 +22,7 @@ DOCUMENTATION = ''' webhook_url: required: true description: Slack Webhook URL. + type: str env: - name: SLACK_WEBHOOK_URL ini: @@ -30,6 +31,7 @@ DOCUMENTATION = ''' channel: default: "#ansible" description: Slack room to post in. + type: str env: - name: SLACK_CHANNEL ini: @@ -37,6 +39,7 @@ DOCUMENTATION = ''' key: channel username: description: Username to post as. + type: str env: - name: SLACK_USERNAME default: ansible diff --git a/plugins/callback/splunk.py b/plugins/callback/splunk.py index d15547f44b..b2ce48de25 100644 --- a/plugins/callback/splunk.py +++ b/plugins/callback/splunk.py @@ -22,6 +22,7 @@ DOCUMENTATION = ''' options: url: description: URL to the Splunk HTTP collector source. + type: str env: - name: SPLUNK_URL ini: @@ -29,6 +30,7 @@ DOCUMENTATION = ''' key: url authtoken: description: Token to authenticate the connection to the Splunk HTTP collector. + type: str env: - name: SPLUNK_AUTHTOKEN ini: @@ -88,13 +90,16 @@ import uuid import socket import getpass -from datetime import datetime from os.path import basename from ansible.module_utils.urls import open_url from ansible.parsing.ajson import AnsibleJSONEncoder from ansible.plugins.callback import CallbackBase +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + class SplunkHTTPCollectorSource(object): def __init__(self): @@ -134,7 +139,7 @@ class SplunkHTTPCollectorSource(object): else: time_format = '%Y-%m-%d %H:%M:%S +0000' - data['timestamp'] = datetime.utcnow().strftime(time_format) + data['timestamp'] = now().strftime(time_format) data['host'] = self.host data['ip_address'] = self.ip_address data['user'] = self.user @@ -181,7 +186,7 @@ class CallbackModule(CallbackBase): def _runtime(self, result): return ( - datetime.utcnow() - + now() - self.start_datetimes[result._task._uuid] ).total_seconds() @@ -220,10 +225,10 @@ class CallbackModule(CallbackBase): self.splunk.ansible_playbook = basename(playbook._file_name) def v2_playbook_on_task_start(self, task, is_conditional): - self.start_datetimes[task._uuid] = datetime.utcnow() + self.start_datetimes[task._uuid] = now() def v2_playbook_on_handler_task_start(self, task): - self.start_datetimes[task._uuid] = datetime.utcnow() + self.start_datetimes[task._uuid] = now() def v2_runner_on_ok(self, result, **kwargs): self.splunk.send_event( diff --git a/plugins/callback/sumologic.py b/plugins/callback/sumologic.py index 46ab3f0f7c..32ca6e0ed0 100644 --- a/plugins/callback/sumologic.py +++ b/plugins/callback/sumologic.py @@ -20,6 +20,7 @@ requirements: options: url: description: URL to the Sumologic HTTP collector source. + type: str env: - name: SUMOLOGIC_URL ini: @@ -46,13 +47,16 @@ import uuid import socket import getpass -from datetime import datetime from os.path import basename from ansible.module_utils.urls import open_url from ansible.parsing.ajson import AnsibleJSONEncoder from ansible.plugins.callback import CallbackBase +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + class SumologicHTTPCollectorSource(object): def __init__(self): @@ -84,8 +88,7 @@ class SumologicHTTPCollectorSource(object): data['uuid'] = result._task._uuid data['session'] = self.session data['status'] = state - data['timestamp'] = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S ' - '+0000') + data['timestamp'] = now().strftime('%Y-%m-%d %H:%M:%S +0000') data['host'] = self.host data['ip_address'] = self.ip_address data['user'] = self.user @@ -123,7 +126,7 @@ class CallbackModule(CallbackBase): def _runtime(self, result): return ( - datetime.utcnow() - + now() - self.start_datetimes[result._task._uuid] ).total_seconds() @@ -144,10 +147,10 @@ class CallbackModule(CallbackBase): self.sumologic.ansible_playbook = basename(playbook._file_name) def v2_playbook_on_task_start(self, task, is_conditional): - self.start_datetimes[task._uuid] = datetime.utcnow() + self.start_datetimes[task._uuid] = now() def v2_playbook_on_handler_task_start(self, task): - self.start_datetimes[task._uuid] = datetime.utcnow() + self.start_datetimes[task._uuid] = now() def v2_runner_on_ok(self, result, **kwargs): self.sumologic.send_event( diff --git a/plugins/callback/syslog_json.py b/plugins/callback/syslog_json.py index 43d6ff2f9f..9066d8d9c5 100644 --- a/plugins/callback/syslog_json.py +++ b/plugins/callback/syslog_json.py @@ -19,6 +19,7 @@ DOCUMENTATION = ''' options: server: description: Syslog server that will receive the event. + type: str env: - name: SYSLOG_SERVER default: localhost @@ -27,6 +28,7 @@ DOCUMENTATION = ''' key: syslog_server port: description: Port on which the syslog server is listening. + type: int env: - name: SYSLOG_PORT default: 514 @@ -35,6 +37,7 @@ DOCUMENTATION = ''' key: syslog_port facility: description: Syslog facility to log as. + type: str env: - name: SYSLOG_FACILITY default: user diff --git a/plugins/callback/timestamp.py b/plugins/callback/timestamp.py new file mode 100644 index 0000000000..07cd8d239c --- /dev/null +++ b/plugins/callback/timestamp.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, kurokobo +# Copyright (c) 2014, Michael DeHaan +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = r""" + name: timestamp + type: stdout + short_description: Adds simple timestamp for each header + version_added: 9.0.0 + description: + - This callback adds simple timestamp for each header. + author: kurokobo (@kurokobo) + options: + timezone: + description: + - Timezone to use for the timestamp in IANA time zone format. + - For example C(America/New_York), C(Asia/Tokyo)). Ignored on Python < 3.9. + ini: + - section: callback_timestamp + key: timezone + env: + - name: ANSIBLE_CALLBACK_TIMESTAMP_TIMEZONE + type: string + format_string: + description: + - Format of the timestamp shown to user in 1989 C standard format. + - > + Refer to L(the Python documentation,https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) + for the available format codes. + ini: + - section: callback_timestamp + key: format_string + env: + - name: ANSIBLE_CALLBACK_TIMESTAMP_FORMAT_STRING + default: "%H:%M:%S" + type: string + seealso: + - plugin: ansible.posix.profile_tasks + plugin_type: callback + description: > + You can use P(ansible.posix.profile_tasks#callback) callback plugin to time individual tasks and overall execution time + with detailed timestamps. + extends_documentation_fragment: + - ansible.builtin.default_callback + - ansible.builtin.result_format_callback +""" + + +from ansible.plugins.callback.default import CallbackModule as Default +from ansible.utils.display import get_text_width +from ansible.module_utils.common.text.converters import to_text +from datetime import datetime +import types +import sys + +# Store whether the zoneinfo module is available +_ZONEINFO_AVAILABLE = sys.version_info >= (3, 9) + + +def get_datetime_now(tz): + """ + Returns the current timestamp with the specified timezone + """ + return datetime.now(tz=tz) + + +def banner(self, msg, color=None, cows=True): + """ + Prints a header-looking line with cowsay or stars with length depending on terminal width (3 minimum) with trailing timestamp + + Based on the banner method of Display class from ansible.utils.display + + https://github.com/ansible/ansible/blob/4403519afe89138042108e237aef317fd5f09c33/lib/ansible/utils/display.py#L511 + """ + timestamp = get_datetime_now(self.timestamp_tzinfo).strftime(self.timestamp_format_string) + timestamp_len = get_text_width(timestamp) + 1 # +1 for leading space + + msg = to_text(msg) + if self.b_cowsay and cows: + try: + self.banner_cowsay("%s @ %s" % (msg, timestamp)) + return + except OSError: + self.warning("somebody cleverly deleted cowsay or something during the PB run. heh.") + + msg = msg.strip() + try: + star_len = self.columns - get_text_width(msg) - timestamp_len + except EnvironmentError: + star_len = self.columns - len(msg) - timestamp_len + if star_len <= 3: + star_len = 3 + stars = "*" * star_len + self.display("\n%s %s %s" % (msg, stars, timestamp), color=color) + + +class CallbackModule(Default): + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = "stdout" + CALLBACK_NAME = "community.general.timestamp" + + def __init__(self): + super(CallbackModule, self).__init__() + + # Replace the banner method of the display object with the custom one + self._display.banner = types.MethodType(banner, self._display) + + def set_options(self, task_keys=None, var_options=None, direct=None): + super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct) + + # Store zoneinfo for specified timezone if available + tzinfo = None + if _ZONEINFO_AVAILABLE and self.get_option("timezone"): + from zoneinfo import ZoneInfo + + tzinfo = ZoneInfo(self.get_option("timezone")) + + # Inject options into the display object + setattr(self._display, "timestamp_tzinfo", tzinfo) + setattr(self._display, "timestamp_format_string", self.get_option("format_string")) diff --git a/plugins/callback/yaml.py b/plugins/callback/yaml.py index ae2c8f8810..e41f69ec53 100644 --- a/plugins/callback/yaml.py +++ b/plugins/callback/yaml.py @@ -19,6 +19,16 @@ DOCUMENTATION = ''' - default_callback requirements: - set as stdout in configuration + seealso: + - plugin: ansible.builtin.default + plugin_type: callback + description: > + There is a parameter O(ansible.builtin.default#callback:result_format) in P(ansible.builtin.default#callback) + that allows you to change the output format to YAML. + notes: + - > + With ansible-core 2.13 or newer, you can instead specify V(yaml) for the parameter O(ansible.builtin.default#callback:result_format) + in P(ansible.builtin.default#callback). ''' import yaml diff --git a/plugins/connection/chroot.py b/plugins/connection/chroot.py index 810316aaa5..3567912359 100644 --- a/plugins/connection/chroot.py +++ b/plugins/connection/chroot.py @@ -20,6 +20,7 @@ DOCUMENTATION = ''' remote_addr: description: - The path of the chroot you want to access. + type: string default: inventory_hostname vars: - name: inventory_hostname @@ -27,6 +28,7 @@ DOCUMENTATION = ''' executable: description: - User specified executable shell + type: string ini: - section: defaults key: executable @@ -38,6 +40,7 @@ DOCUMENTATION = ''' chroot_exe: description: - User specified chroot binary + type: string ini: - section: chroot_connection key: exe diff --git a/plugins/connection/funcd.py b/plugins/connection/funcd.py index 219a8cccd3..7765f53110 100644 --- a/plugins/connection/funcd.py +++ b/plugins/connection/funcd.py @@ -21,6 +21,7 @@ DOCUMENTATION = ''' remote_addr: description: - The path of the chroot you want to access. + type: string default: inventory_hostname vars: - name: ansible_host diff --git a/plugins/connection/incus.py b/plugins/connection/incus.py index 81d6f971c7..8adea2d13a 100644 --- a/plugins/connection/incus.py +++ b/plugins/connection/incus.py @@ -19,6 +19,7 @@ DOCUMENTATION = """ remote_addr: description: - The instance identifier. + type: string default: inventory_hostname vars: - name: inventory_hostname @@ -27,6 +28,7 @@ DOCUMENTATION = """ executable: description: - The shell to use for execution inside the instance. + type: string default: /bin/sh vars: - name: ansible_executable @@ -35,6 +37,7 @@ DOCUMENTATION = """ description: - The name of the Incus remote to use (per C(incus remote list)). - Remotes are used to access multiple servers from a single client. + type: string default: local vars: - name: ansible_incus_remote @@ -42,6 +45,7 @@ DOCUMENTATION = """ description: - The name of the Incus project to use (per C(incus project list)). - Projects are used to divide the instances running on a server. + type: string default: default vars: - name: ansible_incus_project diff --git a/plugins/connection/iocage.py b/plugins/connection/iocage.py index 2e2a6f0937..79d4f88594 100644 --- a/plugins/connection/iocage.py +++ b/plugins/connection/iocage.py @@ -20,12 +20,14 @@ DOCUMENTATION = ''' remote_addr: description: - Path to the jail + type: string vars: - name: ansible_host - name: ansible_iocage_host remote_user: description: - User to execute as inside the jail + type: string vars: - name: ansible_user - name: ansible_iocage_user diff --git a/plugins/connection/jail.py b/plugins/connection/jail.py index 3a3edd4b18..7d0abdde3a 100644 --- a/plugins/connection/jail.py +++ b/plugins/connection/jail.py @@ -20,6 +20,7 @@ DOCUMENTATION = ''' remote_addr: description: - Path to the jail + type: string default: inventory_hostname vars: - name: inventory_hostname @@ -28,6 +29,7 @@ DOCUMENTATION = ''' remote_user: description: - User to execute as inside the jail + type: string vars: - name: ansible_user - name: ansible_jail_user diff --git a/plugins/connection/lxc.py b/plugins/connection/lxc.py index 7bb5824fac..2710e6984e 100644 --- a/plugins/connection/lxc.py +++ b/plugins/connection/lxc.py @@ -17,6 +17,7 @@ DOCUMENTATION = ''' remote_addr: description: - Container identifier + type: string default: inventory_hostname vars: - name: inventory_hostname @@ -26,6 +27,7 @@ DOCUMENTATION = ''' default: /bin/sh description: - Shell executable + type: string vars: - name: ansible_executable - name: ansible_lxc_executable diff --git a/plugins/connection/lxd.py b/plugins/connection/lxd.py index 0e784b85fd..d850907182 100644 --- a/plugins/connection/lxd.py +++ b/plugins/connection/lxd.py @@ -19,6 +19,7 @@ DOCUMENTATION = ''' - Instance (container/VM) identifier. - Since community.general 8.0.0, a FQDN can be provided; in that case, the first component (the part before C(.)) is used as the instance identifier. + type: string default: inventory_hostname vars: - name: inventory_hostname @@ -27,6 +28,7 @@ DOCUMENTATION = ''' executable: description: - Shell to use for execution inside instance. + type: string default: /bin/sh vars: - name: ansible_executable @@ -34,6 +36,7 @@ DOCUMENTATION = ''' remote: description: - Name of the LXD remote to use. + type: string default: local vars: - name: ansible_lxd_remote @@ -41,6 +44,7 @@ DOCUMENTATION = ''' project: description: - Name of the LXD project to use. + type: string vars: - name: ansible_lxd_project version_added: 2.0.0 diff --git a/plugins/connection/qubes.py b/plugins/connection/qubes.py index 25594e952b..b54eeb3a84 100644 --- a/plugins/connection/qubes.py +++ b/plugins/connection/qubes.py @@ -25,14 +25,16 @@ DOCUMENTATION = ''' options: remote_addr: description: - - vm name + - VM name. + type: string default: inventory_hostname vars: - name: ansible_host remote_user: description: - - The user to execute as inside the vm. - default: The *user* account as default in Qubes OS. + - The user to execute as inside the VM. + type: string + default: The I(user) account as default in Qubes OS. vars: - name: ansible_user # keyword: diff --git a/plugins/connection/zone.py b/plugins/connection/zone.py index 34827c7e37..0a591143e0 100644 --- a/plugins/connection/zone.py +++ b/plugins/connection/zone.py @@ -16,11 +16,12 @@ DOCUMENTATION = ''' name: zone short_description: Run tasks in a zone instance description: - - Run commands or put/fetch files to an existing zone + - Run commands or put/fetch files to an existing zone. options: remote_addr: description: - Zone identifier + type: string default: inventory_hostname vars: - name: ansible_host diff --git a/plugins/doc_fragments/consul.py b/plugins/doc_fragments/consul.py index fbe3f33d4d..d4cf119958 100644 --- a/plugins/doc_fragments/consul.py +++ b/plugins/doc_fragments/consul.py @@ -56,5 +56,4 @@ attributes: support: full membership: - community.general.consul - version_added: 8.3.0 """ diff --git a/plugins/doc_fragments/django.py b/plugins/doc_fragments/django.py new file mode 100644 index 0000000000..f89ec91448 --- /dev/null +++ b/plugins/doc_fragments/django.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + DOCUMENTATION = r''' +options: + venv: + description: + - Use the the Python interpreter from this virtual environment. + - Pass the path to the root of the virtualenv, not the C(bin/) directory nor the C(python) executable. + type: path + settings: + description: + - Specifies the settings module to use. + - The value will be passed as is to the C(--settings) argument in C(django-admin). + type: str + required: true + pythonpath: + description: + - Adds the given filesystem path to the Python import search path. + - The value will be passed as is to the C(--pythonpath) argument in C(django-admin). + type: path + traceback: + description: + - Provides a full stack trace in the output when a C(CommandError) is raised. + type: bool + verbosity: + description: + - Specifies the amount of notification and debug information in the output of C(django-admin). + type: int + choices: [0, 1, 2, 3] + skip_checks: + description: + - Skips running system checks prior to running the command. + type: bool + + +notes: + - The C(django-admin) command is always executed using the C(C) locale, and the option C(--no-color) is always passed. + +seealso: + - name: django-admin and manage.py in official Django documentation + description: >- + Refer to this documentation for the builtin commands and options of C(django-admin). + Please make sure that you select the right version of Django in the version selector on that page. + link: https://docs.djangoproject.com/en/5.0/ref/django-admin/ +''' + + DATABASE = r''' +options: + database: + description: + - Specify the database to be used. + type: str + default: default +''' diff --git a/plugins/doc_fragments/proxmox.py b/plugins/doc_fragments/proxmox.py index 4972da4985..239dba06da 100644 --- a/plugins/doc_fragments/proxmox.py +++ b/plugins/doc_fragments/proxmox.py @@ -16,6 +16,13 @@ options: - Specify the target host of the Proxmox VE cluster. type: str required: true + api_port: + description: + - Specify the target port of the Proxmox VE cluster. + - Uses the E(PROXMOX_PORT) environment variable if not specified. + type: int + required: false + version_added: 9.1.0 api_user: description: - Specify the user to authenticate with. @@ -65,3 +72,13 @@ options: - Add the new VM to the specified pool. type: str ''' + + ACTIONGROUP_PROXMOX = r""" +options: {} +attributes: + action_group: + description: Use C(group/community.general.proxmox) in C(module_defaults) to set defaults for this module. + support: full + membership: + - community.general.proxmox +""" diff --git a/plugins/doc_fragments/rackspace.py b/plugins/doc_fragments/rackspace.py deleted file mode 100644 index f28be777ca..0000000000 --- a/plugins/doc_fragments/rackspace.py +++ /dev/null @@ -1,120 +0,0 @@ -# -*- coding: utf-8 -*- - -# Copyright (c) 2014, Matt Martz -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - - -class ModuleDocFragment(object): - - # Standard Rackspace only documentation fragment - DOCUMENTATION = r''' -options: - api_key: - description: - - Rackspace API key, overrides O(credentials). - type: str - aliases: [ password ] - credentials: - description: - - File to find the Rackspace credentials in. Ignored if O(api_key) and - O(username) are provided. - type: path - aliases: [ creds_file ] - env: - description: - - Environment as configured in C(~/.pyrax.cfg), - see U(https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md#pyrax-configuration). - type: str - region: - description: - - Region to create an instance in. - type: str - username: - description: - - Rackspace username, overrides O(credentials). - type: str - validate_certs: - description: - - Whether or not to require SSL validation of API endpoints. - type: bool - aliases: [ verify_ssl ] -requirements: - - pyrax -notes: - - The following environment variables can be used, E(RAX_USERNAME), - E(RAX_API_KEY), E(RAX_CREDS_FILE), E(RAX_CREDENTIALS), E(RAX_REGION). - - E(RAX_CREDENTIALS) and E(RAX_CREDS_FILE) point to a credentials file - appropriate for pyrax. See U(https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md#authenticating). - - E(RAX_USERNAME) and E(RAX_API_KEY) obviate the use of a credentials file. - - E(RAX_REGION) defines a Rackspace Public Cloud region (DFW, ORD, LON, ...). -''' - - # Documentation fragment including attributes to enable communication - # of other OpenStack clouds. Not all rax modules support this. - OPENSTACK = r''' -options: - api_key: - type: str - description: - - Rackspace API key, overrides O(credentials). - aliases: [ password ] - auth_endpoint: - type: str - description: - - The URI of the authentication service. - - If not specified will be set to U(https://identity.api.rackspacecloud.com/v2.0/). - credentials: - type: path - description: - - File to find the Rackspace credentials in. Ignored if O(api_key) and - O(username) are provided. - aliases: [ creds_file ] - env: - type: str - description: - - Environment as configured in C(~/.pyrax.cfg), - see U(https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md#pyrax-configuration). - identity_type: - type: str - description: - - Authentication mechanism to use, such as rackspace or keystone. - default: rackspace - region: - type: str - description: - - Region to create an instance in. - tenant_id: - type: str - description: - - The tenant ID used for authentication. - tenant_name: - type: str - description: - - The tenant name used for authentication. - username: - type: str - description: - - Rackspace username, overrides O(credentials). - validate_certs: - description: - - Whether or not to require SSL validation of API endpoints. - type: bool - aliases: [ verify_ssl ] -deprecated: - removed_in: 9.0.0 - why: This module relies on the deprecated package pyrax. - alternative: Use the Openstack modules instead. -requirements: - - pyrax -notes: - - The following environment variables can be used, E(RAX_USERNAME), - E(RAX_API_KEY), E(RAX_CREDS_FILE), E(RAX_CREDENTIALS), E(RAX_REGION). - - E(RAX_CREDENTIALS) and E(RAX_CREDS_FILE) points to a credentials file - appropriate for pyrax. See U(https://github.com/rackspace/pyrax/blob/master/docs/getting_started.md#authenticating). - - E(RAX_USERNAME) and E(RAX_API_KEY) obviate the use of a credentials file. - - E(RAX_REGION) defines a Rackspace Public Cloud region (DFW, ORD, LON, ...). -''' diff --git a/plugins/doc_fragments/redis.py b/plugins/doc_fragments/redis.py index fafb52c86c..69fd0c9cd5 100644 --- a/plugins/doc_fragments/redis.py +++ b/plugins/doc_fragments/redis.py @@ -49,6 +49,16 @@ options: - Path to root certificates file. If not set and O(tls) is set to V(true), certifi ca-certificates will be used. type: str + client_cert_file: + description: + - Path to the client certificate file. + type: str + version_added: 9.3.0 + client_key_file: + description: + - Path to the client private key file. + type: str + version_added: 9.3.0 requirements: [ "redis", "certifi" ] notes: diff --git a/plugins/filter/from_ini.py b/plugins/filter/from_ini.py index d68b51092e..6fe83875e6 100644 --- a/plugins/filter/from_ini.py +++ b/plugins/filter/from_ini.py @@ -57,7 +57,7 @@ class IniParser(ConfigParser): ''' Implements a configparser which is able to return a dict ''' def __init__(self): - super().__init__() + super().__init__(interpolation=None) self.optionxform = str def as_dict(self): diff --git a/plugins/filter/hashids.py b/plugins/filter/hashids.py index 45fba83c03..ac771e6219 100644 --- a/plugins/filter/hashids.py +++ b/plugins/filter/hashids.py @@ -27,7 +27,7 @@ def initialize_hashids(**kwargs): if not HAS_HASHIDS: raise AnsibleError("The hashids library must be installed in order to use this plugin") - params = dict((k, v) for k, v in kwargs.items() if v) + params = {k: v for k, v in kwargs.items() if v} try: return Hashids(**params) diff --git a/plugins/filter/keep_keys.py b/plugins/filter/keep_keys.py new file mode 100644 index 0000000000..97b706a950 --- /dev/null +++ b/plugins/filter/keep_keys.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Vladimir Botka +# Copyright (c) 2024 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: keep_keys + short_description: Keep specific keys from dictionaries in a list + version_added: "9.1.0" + author: + - Vladimir Botka (@vbotka) + - Felix Fontein (@felixfontein) + description: This filter keeps only specified keys from a provided list of dictionaries. + options: + _input: + description: + - A list of dictionaries. + - Top level keys must be strings. + type: list + elements: dictionary + required: true + target: + description: + - A single key or key pattern to keep, or a list of keys or keys patterns to keep. + - If O(matching_parameter=regex) there must be exactly one pattern provided. + type: raw + required: true + matching_parameter: + description: Specify the matching option of target keys. + type: str + default: equal + choices: + equal: Matches keys of exactly one of the O(target) items. + starts_with: Matches keys that start with one of the O(target) items. + ends_with: Matches keys that end with one of the O(target) items. + regex: + - Matches keys that match the regular expresion provided in O(target). + - In this case, O(target) must be a regex string or a list with single regex string. +''' + +EXAMPLES = ''' + l: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + + # 1) By default match keys that equal any of the items in the target. + t: [k0_x0, k1_x1] + r: "{{ l | community.general.keep_keys(target=t) }}" + + # 2) Match keys that start with any of the items in the target. + t: [k0, k1] + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='starts_with') }}" + + # 3) Match keys that end with any of the items in target. + t: [x0, x1] + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='ends_with') }}" + + # 4) Match keys by the regex. + t: ['^.*[01]_x.*$'] + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='regex') }}" + + # 5) Match keys by the regex. + t: '^.*[01]_x.*$' + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='regex') }}" + + # The results of above examples 1-5 are all the same. + r: + - {k0_x0: A0, k1_x1: B0} + - {k0_x0: A1, k1_x1: B1} + + # 6) By default match keys that equal the target. + t: k0_x0 + r: "{{ l | community.general.keep_keys(target=t) }}" + + # 7) Match keys that start with the target. + t: k0 + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='starts_with') }}" + + # 8) Match keys that end with the target. + t: x0 + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='ends_with') }}" + + # 9) Match keys by the regex. + t: '^.*0_x.*$' + r: "{{ l | community.general.keep_keys(target=t, matching_parameter='regex') }}" + + # The results of above examples 6-9 are all the same. + r: + - {k0_x0: A0} + - {k0_x0: A1} +''' + +RETURN = ''' + _value: + description: The list of dictionaries with selected keys. + type: list + elements: dictionary +''' + +from ansible_collections.community.general.plugins.plugin_utils.keys_filter import ( + _keys_filter_params, + _keys_filter_target_str) + + +def keep_keys(data, target=None, matching_parameter='equal'): + """keep specific keys from dictionaries in a list""" + + # test parameters + _keys_filter_params(data, matching_parameter) + # test and transform target + tt = _keys_filter_target_str(target, matching_parameter) + + if matching_parameter == 'equal': + def keep_key(key): + return key in tt + elif matching_parameter == 'starts_with': + def keep_key(key): + return key.startswith(tt) + elif matching_parameter == 'ends_with': + def keep_key(key): + return key.endswith(tt) + elif matching_parameter == 'regex': + def keep_key(key): + return tt.match(key) is not None + + return [{k: v for k, v in d.items() if keep_key(k)} for d in data] + + +class FilterModule(object): + + def filters(self): + return { + 'keep_keys': keep_keys, + } diff --git a/plugins/filter/lists_mergeby.py b/plugins/filter/lists_mergeby.py index caf183492c..0e47d50172 100644 --- a/plugins/filter/lists_mergeby.py +++ b/plugins/filter/lists_mergeby.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2020-2022, Vladimir Botka +# Copyright (c) 2020-2024, Vladimir Botka # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later @@ -12,22 +12,32 @@ DOCUMENTATION = ''' version_added: 2.0.0 author: Vladimir Botka (@vbotka) description: - - Merge two or more lists by attribute O(index). Optional parameters O(recursive) and O(list_merge) - control the merging of the lists in values. The function merge_hash from ansible.utils.vars - is used. To learn details on how to use the parameters O(recursive) and O(list_merge) see - Ansible User's Guide chapter "Using filters to manipulate data" section "Combining - hashes/dictionaries". + - Merge two or more lists by attribute O(index). Optional + parameters O(recursive) and O(list_merge) control the merging of + the nested dictionaries and lists. + - The function C(merge_hash) from C(ansible.utils.vars) is used. + - To learn details on how to use the parameters O(recursive) and + O(list_merge) see Ansible User's Guide chapter "Using filters to + manipulate data" section R(Combining hashes/dictionaries, combine_filter) or the + filter P(ansible.builtin.combine#filter). + positional: another_list, index options: _input: - description: A list of dictionaries. + description: + - A list of dictionaries, or a list of lists of dictionaries. + - The required type of the C(elements) is set to C(raw) + because all elements of O(_input) can be either dictionaries + or lists. type: list - elements: dictionary + elements: raw required: true another_list: - description: Another list of dictionaries. This parameter can be specified multiple times. + description: + - Another list of dictionaries, or a list of lists of dictionaries. + - This parameter can be specified multiple times. type: list - elements: dictionary + elements: raw index: description: - The dictionary key that must be present in every dictionary in every list that is used to @@ -55,40 +65,134 @@ DOCUMENTATION = ''' ''' EXAMPLES = ''' -- name: Merge two lists +# Some results below are manually formatted for better readability. The +# dictionaries' keys will be sorted alphabetically in real output. + +- name: Example 1. Merge two lists. The results r1 and r2 are the same. ansible.builtin.debug: - msg: >- - {{ list1 | community.general.lists_mergeby( - list2, - 'index', - recursive=True, - list_merge='append' - ) }}" + msg: | + r1: {{ r1 }} + r2: {{ r2 }} vars: list1: - - index: a - value: 123 - - index: b - value: 42 + - {index: a, value: 123} + - {index: b, value: 4} list2: - - index: a - foo: bar - - index: c - foo: baz - # Produces the following list of dictionaries: - # { - # "index": "a", - # "foo": "bar", - # "value": 123 - # }, - # { - # "index": "b", - # "value": 42 - # }, - # { - # "index": "c", - # "foo": "baz" - # } + - {index: a, foo: bar} + - {index: c, foo: baz} + r1: "{{ list1 | community.general.lists_mergeby(list2, 'index') }}" + r2: "{{ [list1, list2] | community.general.lists_mergeby('index') }}" + +# r1: +# - {index: a, foo: bar, value: 123} +# - {index: b, value: 4} +# - {index: c, foo: baz} +# r2: +# - {index: a, foo: bar, value: 123} +# - {index: b, value: 4} +# - {index: c, foo: baz} + +- name: Example 2. Merge three lists + ansible.builtin.debug: + var: r + vars: + list1: + - {index: a, value: 123} + - {index: b, value: 4} + list2: + - {index: a, foo: bar} + - {index: c, foo: baz} + list3: + - {index: d, foo: qux} + r: "{{ [list1, list2, list3] | community.general.lists_mergeby('index') }}" + +# r: +# - {index: a, foo: bar, value: 123} +# - {index: b, value: 4} +# - {index: c, foo: baz} +# - {index: d, foo: qux} + +- name: Example 3. Merge single list. The result is the same as 2. + ansible.builtin.debug: + var: r + vars: + list1: + - {index: a, value: 123} + - {index: b, value: 4} + - {index: a, foo: bar} + - {index: c, foo: baz} + - {index: d, foo: qux} + r: "{{ [list1, []] | community.general.lists_mergeby('index') }}" + +# r: +# - {index: a, foo: bar, value: 123} +# - {index: b, value: 4} +# - {index: c, foo: baz} +# - {index: d, foo: qux} + +- name: Example 4. Merge two lists. By default, replace nested lists. + ansible.builtin.debug: + var: r + vars: + list1: + - {index: a, foo: [X1, X2]} + - {index: b, foo: [X1, X2]} + list2: + - {index: a, foo: [Y1, Y2]} + - {index: b, foo: [Y1, Y2]} + r: "{{ [list1, list2] | community.general.lists_mergeby('index') }}" + +# r: +# - {index: a, foo: [Y1, Y2]} +# - {index: b, foo: [Y1, Y2]} + +- name: Example 5. Merge two lists. Append nested lists. + ansible.builtin.debug: + var: r + vars: + list1: + - {index: a, foo: [X1, X2]} + - {index: b, foo: [X1, X2]} + list2: + - {index: a, foo: [Y1, Y2]} + - {index: b, foo: [Y1, Y2]} + r: "{{ [list1, list2] | community.general.lists_mergeby('index', list_merge='append') }}" + +# r: +# - {index: a, foo: [X1, X2, Y1, Y2]} +# - {index: b, foo: [X1, X2, Y1, Y2]} + +- name: Example 6. Merge two lists. By default, do not merge nested dictionaries. + ansible.builtin.debug: + var: r + vars: + list1: + - {index: a, foo: {x: 1, y: 2}} + - {index: b, foo: [X1, X2]} + list2: + - {index: a, foo: {y: 3, z: 4}} + - {index: b, foo: [Y1, Y2]} + r: "{{ [list1, list2] | community.general.lists_mergeby('index') }}" + +# r: +# - {index: a, foo: {y: 3, z: 4}} +# - {index: b, foo: [Y1, Y2]} + +- name: Example 7. Merge two lists. Merge nested dictionaries too. + ansible.builtin.debug: + var: r + vars: + list1: + - {index: a, foo: {x: 1, y: 2}} + - {index: b, foo: [X1, X2]} + list2: + - {index: a, foo: {y: 3, z: 4}} + - {index: b, foo: [Y1, Y2]} + r: "{{ [list1, list2] | community.general.lists_mergeby('index', recursive=true) }}" + +# r: +# - {index: a, foo: {x:1, y: 3, z: 4}} +# - {index: b, foo: [Y1, Y2]} ''' RETURN = ''' @@ -108,13 +212,14 @@ from operator import itemgetter def list_mergeby(x, y, index, recursive=False, list_merge='replace'): - ''' Merge 2 lists by attribute 'index'. The function merge_hash from ansible.utils.vars is used. - This function is used by the function lists_mergeby. + '''Merge 2 lists by attribute 'index'. The function 'merge_hash' + from ansible.utils.vars is used. This function is used by the + function lists_mergeby. ''' d = defaultdict(dict) - for l in (x, y): - for elem in l: + for lst in (x, y): + for elem in lst: if not isinstance(elem, Mapping): msg = "Elements of list arguments for lists_mergeby must be dictionaries. %s is %s" raise AnsibleFilterError(msg % (elem, type(elem))) @@ -124,20 +229,9 @@ def list_mergeby(x, y, index, recursive=False, list_merge='replace'): def lists_mergeby(*terms, **kwargs): - ''' Merge 2 or more lists by attribute 'index'. Optional parameters 'recursive' and 'list_merge' - control the merging of the lists in values. The function merge_hash from ansible.utils.vars - is used. To learn details on how to use the parameters 'recursive' and 'list_merge' see - Ansible User's Guide chapter "Using filters to manipulate data" section "Combining - hashes/dictionaries". - - Example: - - debug: - msg: "{{ list1| - community.general.lists_mergeby(list2, - 'index', - recursive=True, - list_merge='append')| - list }}" + '''Merge 2 or more lists by attribute 'index'. To learn details + on how to use the parameters 'recursive' and 'list_merge' see + the filter ansible.builtin.combine. ''' recursive = kwargs.pop('recursive', False) @@ -155,7 +249,7 @@ def lists_mergeby(*terms, **kwargs): "must be lists. %s is %s") raise AnsibleFilterError(msg % (sublist, type(sublist))) if len(sublist) > 0: - if all(isinstance(l, Sequence) for l in sublist): + if all(isinstance(lst, Sequence) for lst in sublist): for item in sublist: flat_list.append(item) else: diff --git a/plugins/filter/remove_keys.py b/plugins/filter/remove_keys.py new file mode 100644 index 0000000000..7a4d912d34 --- /dev/null +++ b/plugins/filter/remove_keys.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Vladimir Botka +# Copyright (c) 2024 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: remove_keys + short_description: Remove specific keys from dictionaries in a list + version_added: "9.1.0" + author: + - Vladimir Botka (@vbotka) + - Felix Fontein (@felixfontein) + description: This filter removes only specified keys from a provided list of dictionaries. + options: + _input: + description: + - A list of dictionaries. + - Top level keys must be strings. + type: list + elements: dictionary + required: true + target: + description: + - A single key or key pattern to remove, or a list of keys or keys patterns to remove. + - If O(matching_parameter=regex) there must be exactly one pattern provided. + type: raw + required: true + matching_parameter: + description: Specify the matching option of target keys. + type: str + default: equal + choices: + equal: Matches keys of exactly one of the O(target) items. + starts_with: Matches keys that start with one of the O(target) items. + ends_with: Matches keys that end with one of the O(target) items. + regex: + - Matches keys that match the regular expresion provided in O(target). + - In this case, O(target) must be a regex string or a list with single regex string. +''' + +EXAMPLES = ''' + l: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + + # 1) By default match keys that equal any of the items in the target. + t: [k0_x0, k1_x1] + r: "{{ l | community.general.remove_keys(target=t) }}" + + # 2) Match keys that start with any of the items in the target. + t: [k0, k1] + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='starts_with') }}" + + # 3) Match keys that end with any of the items in target. + t: [x0, x1] + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='ends_with') }}" + + # 4) Match keys by the regex. + t: ['^.*[01]_x.*$'] + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='regex') }}" + + # 5) Match keys by the regex. + t: '^.*[01]_x.*$' + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='regex') }}" + + # The results of above examples 1-5 are all the same. + r: + - {k2_x2: [C0], k3_x3: foo} + - {k2_x2: [C1], k3_x3: bar} + + # 6) By default match keys that equal the target. + t: k0_x0 + r: "{{ l | community.general.remove_keys(target=t) }}" + + # 7) Match keys that start with the target. + t: k0 + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='starts_with') }}" + + # 8) Match keys that end with the target. + t: x0 + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='ends_with') }}" + + # 9) Match keys by the regex. + t: '^.*0_x.*$' + r: "{{ l | community.general.remove_keys(target=t, matching_parameter='regex') }}" + + # The results of above examples 6-9 are all the same. + r: + - {k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k1_x1: B1, k2_x2: [C1], k3_x3: bar} +''' + +RETURN = ''' + _value: + description: The list of dictionaries with selected keys removed. + type: list + elements: dictionary +''' + +from ansible_collections.community.general.plugins.plugin_utils.keys_filter import ( + _keys_filter_params, + _keys_filter_target_str) + + +def remove_keys(data, target=None, matching_parameter='equal'): + """remove specific keys from dictionaries in a list""" + + # test parameters + _keys_filter_params(data, matching_parameter) + # test and transform target + tt = _keys_filter_target_str(target, matching_parameter) + + if matching_parameter == 'equal': + def keep_key(key): + return key not in tt + elif matching_parameter == 'starts_with': + def keep_key(key): + return not key.startswith(tt) + elif matching_parameter == 'ends_with': + def keep_key(key): + return not key.endswith(tt) + elif matching_parameter == 'regex': + def keep_key(key): + return tt.match(key) is None + + return [{k: v for k, v in d.items() if keep_key(k)} for d in data] + + +class FilterModule(object): + + def filters(self): + return { + 'remove_keys': remove_keys, + } diff --git a/plugins/filter/replace_keys.py b/plugins/filter/replace_keys.py new file mode 100644 index 0000000000..70b264eba6 --- /dev/null +++ b/plugins/filter/replace_keys.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Vladimir Botka +# Copyright (c) 2024 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: replace_keys + short_description: Replace specific keys in a list of dictionaries + version_added: "9.1.0" + author: + - Vladimir Botka (@vbotka) + - Felix Fontein (@felixfontein) + description: This filter replaces specified keys in a provided list of dictionaries. + options: + _input: + description: + - A list of dictionaries. + - Top level keys must be strings. + type: list + elements: dictionary + required: true + target: + description: + - A list of dictionaries with attributes C(before) and C(after). + - The value of O(target[].after) replaces key matching O(target[].before). + type: list + elements: dictionary + required: true + suboptions: + before: + description: + - A key or key pattern to change. + - The interpretation of O(target[].before) depends on O(matching_parameter). + - For a key that matches multiple O(target[].before)s, the B(first) matching O(target[].after) will be used. + type: str + after: + description: A matching key change to. + type: str + matching_parameter: + description: Specify the matching option of target keys. + type: str + default: equal + choices: + equal: Matches keys of exactly one of the O(target[].before) items. + starts_with: Matches keys that start with one of the O(target[].before) items. + ends_with: Matches keys that end with one of the O(target[].before) items. + regex: Matches keys that match one of the regular expressions provided in O(target[].before). +''' + +EXAMPLES = ''' + l: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + + # 1) By default, replace keys that are equal any of the attributes before. + t: + - {before: k0_x0, after: a0} + - {before: k1_x1, after: a1} + r: "{{ l | community.general.replace_keys(target=t) }}" + + # 2) Replace keys that starts with any of the attributes before. + t: + - {before: k0, after: a0} + - {before: k1, after: a1} + r: "{{ l | community.general.replace_keys(target=t, matching_parameter='starts_with') }}" + + # 3) Replace keys that ends with any of the attributes before. + t: + - {before: x0, after: a0} + - {before: x1, after: a1} + r: "{{ l | community.general.replace_keys(target=t, matching_parameter='ends_with') }}" + + # 4) Replace keys that match any regex of the attributes before. + t: + - {before: "^.*0_x.*$", after: a0} + - {before: "^.*1_x.*$", after: a1} + r: "{{ l | community.general.replace_keys(target=t, matching_parameter='regex') }}" + + # The results of above examples 1-4 are all the same. + r: + - {a0: A0, a1: B0, k2_x2: [C0], k3_x3: foo} + - {a0: A1, a1: B1, k2_x2: [C1], k3_x3: bar} + + # 5) If more keys match the same attribute before the last one will be used. + t: + - {before: "^.*_x.*$", after: X} + r: "{{ l | community.general.replace_keys(target=t, matching_parameter='regex') }}" + + # gives + + r: + - X: foo + - X: bar + + # 6) If there are items with equal attribute before the first one will be used. + t: + - {before: "^.*_x.*$", after: X} + - {before: "^.*_x.*$", after: Y} + r: "{{ l | community.general.replace_keys(target=t, matching_parameter='regex') }}" + + # gives + + r: + - X: foo + - X: bar + + # 7) If there are more matches for a key the first one will be used. + l: + - {aaa1: A, bbb1: B, ccc1: C} + - {aaa2: D, bbb2: E, ccc2: F} + t: + - {before: a, after: X} + - {before: aa, after: Y} + r: "{{ l | community.general.replace_keys(target=t, matching_parameter='starts_with') }}" + + # gives + + r: + - {X: A, bbb1: B, ccc1: C} + - {X: D, bbb2: E, ccc2: F} +''' + +RETURN = ''' + _value: + description: The list of dictionaries with replaced keys. + type: list + elements: dictionary +''' + +from ansible_collections.community.general.plugins.plugin_utils.keys_filter import ( + _keys_filter_params, + _keys_filter_target_dict) + + +def replace_keys(data, target=None, matching_parameter='equal'): + """replace specific keys in a list of dictionaries""" + + # test parameters + _keys_filter_params(data, matching_parameter) + # test and transform target + tz = _keys_filter_target_dict(target, matching_parameter) + + if matching_parameter == 'equal': + def replace_key(key): + for b, a in tz: + if key == b: + return a + return key + elif matching_parameter == 'starts_with': + def replace_key(key): + for b, a in tz: + if key.startswith(b): + return a + return key + elif matching_parameter == 'ends_with': + def replace_key(key): + for b, a in tz: + if key.endswith(b): + return a + return key + elif matching_parameter == 'regex': + def replace_key(key): + for b, a in tz: + if b.match(key): + return a + return key + + return [{replace_key(k): v for k, v in d.items()} for d in data] + + +class FilterModule(object): + + def filters(self): + return { + 'replace_keys': replace_keys, + } diff --git a/plugins/filter/reveal_ansible_type.py b/plugins/filter/reveal_ansible_type.py new file mode 100644 index 0000000000..916aaff930 --- /dev/null +++ b/plugins/filter/reveal_ansible_type.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 Vladimir Botka +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: reveal_ansible_type + short_description: Return input type + version_added: "9.2.0" + author: Vladimir Botka (@vbotka) + description: This filter returns input type. + options: + _input: + description: Input data. + type: raw + required: true + alias: + description: Data type aliases. + default: {} + type: dictionary +''' + +EXAMPLES = ''' +# Substitution converts str to AnsibleUnicode +# ------------------------------------------- + +# String. AnsibleUnicode. +data: "abc" +result: '{{ data | community.general.reveal_ansible_type }}' +# result => AnsibleUnicode + +# String. AnsibleUnicode alias str. +alias: {"AnsibleUnicode": "str"} +data: "abc" +result: '{{ data | community.general.reveal_ansible_type(alias) }}' +# result => str + +# List. All items are AnsibleUnicode. +data: ["a", "b", "c"] +result: '{{ data | community.general.reveal_ansible_type }}' +# result => list[AnsibleUnicode] + +# Dictionary. All keys are AnsibleUnicode. All values are AnsibleUnicode. +data: {"a": "foo", "b": "bar", "c": "baz"} +result: '{{ data | community.general.reveal_ansible_type }}' +# result => dict[AnsibleUnicode, AnsibleUnicode] + +# No substitution and no alias. Type of strings is str +# ---------------------------------------------------- + +# String +result: '{{ "abc" | community.general.reveal_ansible_type }}' +# result => str + +# Integer +result: '{{ 123 | community.general.reveal_ansible_type }}' +# result => int + +# Float +result: '{{ 123.45 | community.general.reveal_ansible_type }}' +# result => float + +# Boolean +result: '{{ true | community.general.reveal_ansible_type }}' +# result => bool + +# List. All items are strings. +result: '{{ ["a", "b", "c"] | community.general.reveal_ansible_type }}' +# result => list[str] + +# List of dictionaries. +result: '{{ [{"a": 1}, {"b": 2}] | community.general.reveal_ansible_type }}' +# result => list[dict] + +# Dictionary. All keys are strings. All values are integers. +result: '{{ {"a": 1} | community.general.reveal_ansible_type }}' +# result => dict[str, int] + +# Dictionary. All keys are strings. All values are integers. +result: '{{ {"a": 1, "b": 2} | community.general.reveal_ansible_type }}' +# result => dict[str, int] + +# Type of strings is AnsibleUnicode or str +# ---------------------------------------- + +# Dictionary. The keys are integers or strings. All values are strings. +alias: {"AnsibleUnicode": "str"} +data: {1: 'a', 'b': 'b'} +result: '{{ data | community.general.reveal_ansible_type(alias) }}' +# result => dict[int|str, str] + +# Dictionary. All keys are integers. All values are keys. +alias: {"AnsibleUnicode": "str"} +data: {1: 'a', 2: 'b'} +result: '{{ data | community.general.reveal_ansible_type(alias) }}' +# result => dict[int, str] + +# Dictionary. All keys are strings. Multiple types values. +alias: {"AnsibleUnicode": "str"} +data: {'a': 1, 'b': 1.1, 'c': 'abc', 'd': True, 'e': ['x', 'y', 'z'], 'f': {'x': 1, 'y': 2}} +result: '{{ data | community.general.reveal_ansible_type(alias) }}' +# result => dict[str, bool|dict|float|int|list|str] + +# List. Multiple types items. +alias: {"AnsibleUnicode": "str"} +data: [1, 2, 1.1, 'abc', True, ['x', 'y', 'z'], {'x': 1, 'y': 2}] +result: '{{ data | community.general.reveal_ansible_type(alias) }}' +# result => list[bool|dict|float|int|list|str] +''' + +RETURN = ''' + _value: + description: Type of the data. + type: str +''' + +from ansible_collections.community.general.plugins.plugin_utils.ansible_type import _ansible_type + + +def reveal_ansible_type(data, alias=None): + """Returns data type""" + + return _ansible_type(data, alias) + + +class FilterModule(object): + + def filters(self): + return { + 'reveal_ansible_type': reveal_ansible_type + } diff --git a/plugins/filter/to_ini.py b/plugins/filter/to_ini.py index 22ef16d722..bdf2dde270 100644 --- a/plugins/filter/to_ini.py +++ b/plugins/filter/to_ini.py @@ -63,7 +63,7 @@ class IniParser(ConfigParser): ''' Implements a configparser which sets the correct optionxform ''' def __init__(self): - super().__init__() + super().__init__(interpolation=None) self.optionxform = str diff --git a/plugins/inventory/cobbler.py b/plugins/inventory/cobbler.py index 8ca36f4264..664380da8f 100644 --- a/plugins/inventory/cobbler.py +++ b/plugins/inventory/cobbler.py @@ -21,20 +21,24 @@ DOCUMENTATION = ''' options: plugin: description: The name of this plugin, it should always be set to V(community.general.cobbler) for this plugin to recognize it as it's own. + type: string required: true choices: [ 'cobbler', 'community.general.cobbler' ] url: description: URL to cobbler. + type: string default: 'http://cobbler/cobbler_api' env: - name: COBBLER_SERVER user: description: Cobbler authentication user. + type: string required: false env: - name: COBBLER_USER password: description: Cobbler authentication password. + type: string required: false env: - name: COBBLER_PASSWORD @@ -117,7 +121,8 @@ from ansible.errors import AnsibleError from ansible.module_utils.common.text.converters import to_text from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, to_safe_group_name from ansible.module_utils.six import text_type -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe # xmlrpc try: diff --git a/plugins/inventory/gitlab_runners.py b/plugins/inventory/gitlab_runners.py index 536f4bb1b8..bd29e8d310 100644 --- a/plugins/inventory/gitlab_runners.py +++ b/plugins/inventory/gitlab_runners.py @@ -83,7 +83,8 @@ keyed_groups: from ansible.errors import AnsibleError, AnsibleParserError from ansible.module_utils.common.text.converters import to_native from ansible.plugins.inventory import BaseInventoryPlugin, Constructable -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe try: import gitlab diff --git a/plugins/inventory/icinga2.py b/plugins/inventory/icinga2.py index 6746bb8e0f..d1f2bc617f 100644 --- a/plugins/inventory/icinga2.py +++ b/plugins/inventory/icinga2.py @@ -102,7 +102,8 @@ from ansible.errors import AnsibleParserError from ansible.plugins.inventory import BaseInventoryPlugin, Constructable from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.error import HTTPError -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe class InventoryModule(BaseInventoryPlugin, Constructable): diff --git a/plugins/inventory/linode.py b/plugins/inventory/linode.py index fc79f12c5f..5c9a4718f5 100644 --- a/plugins/inventory/linode.py +++ b/plugins/inventory/linode.py @@ -35,6 +35,7 @@ DOCUMENTATION = r''' version_added: 4.5.0 plugin: description: Marks this as an instance of the 'linode' plugin. + type: string required: true choices: ['linode', 'community.general.linode'] ip_style: @@ -47,6 +48,7 @@ DOCUMENTATION = r''' version_added: 3.6.0 access_token: description: The Linode account personal access token. + type: string required: true env: - name: LINODE_ACCESS_TOKEN @@ -122,7 +124,8 @@ compose: from ansible.errors import AnsibleError from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe try: diff --git a/plugins/inventory/lxd.py b/plugins/inventory/lxd.py index c803f47ddc..9ae004f6c5 100644 --- a/plugins/inventory/lxd.py +++ b/plugins/inventory/lxd.py @@ -20,6 +20,7 @@ DOCUMENTATION = r''' options: plugin: description: Token that ensures this is a source file for the 'lxd' plugin. + type: string required: true choices: [ 'community.general.lxd' ] url: @@ -27,8 +28,8 @@ DOCUMENTATION = r''' - The unix domain socket path or the https URL for the lxd server. - Sockets in filesystem have to start with C(unix:). - Mostly C(unix:/var/lib/lxd/unix.socket) or C(unix:/var/snap/lxd/common/lxd/unix.socket). + type: string default: unix:/var/snap/lxd/common/lxd/unix.socket - type: str client_key: description: - The client certificate key file path. @@ -175,7 +176,7 @@ from ansible.module_utils.six import raise_from from ansible.errors import AnsibleError, AnsibleParserError from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible_collections.community.general.plugins.module_utils.lxd import LXDClient, LXDClientException -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe try: import ipaddress diff --git a/plugins/inventory/nmap.py b/plugins/inventory/nmap.py index 3a28007a31..48f02c446b 100644 --- a/plugins/inventory/nmap.py +++ b/plugins/inventory/nmap.py @@ -20,6 +20,7 @@ DOCUMENTATION = ''' options: plugin: description: token that ensures this is a source file for the 'nmap' plugin. + type: string required: true choices: ['nmap', 'community.general.nmap'] sudo: @@ -29,6 +30,7 @@ DOCUMENTATION = ''' type: boolean address: description: Network IP or range of IPs to scan, you can use a simple range (10.2.2.15-25) or CIDR notation. + type: string required: true env: - name: ANSIBLE_NMAP_ADDRESS @@ -91,7 +93,7 @@ DOCUMENTATION = ''' default: true version_added: 7.4.0 notes: - - At least one of ipv4 or ipv6 is required to be True, both can be True, but they cannot both be False. + - At least one of O(ipv4) or O(ipv6) is required to be V(true); both can be V(true), but they cannot both be V(false). - 'TODO: add OS fingerprinting' ''' EXAMPLES = ''' @@ -126,7 +128,8 @@ from ansible.errors import AnsibleParserError from ansible.module_utils.common.text.converters import to_native, to_text from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable from ansible.module_utils.common.process import get_bin_path -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): diff --git a/plugins/inventory/online.py b/plugins/inventory/online.py index b3a9ecd379..70b8d14192 100644 --- a/plugins/inventory/online.py +++ b/plugins/inventory/online.py @@ -16,11 +16,13 @@ DOCUMENTATION = r''' options: plugin: description: token that ensures this is a source file for the 'online' plugin. + type: string required: true choices: ['online', 'community.general.online'] oauth_token: required: true description: Online OAuth token. + type: string env: # in order of precedence - name: ONLINE_TOKEN @@ -68,7 +70,8 @@ from ansible.plugins.inventory import BaseInventoryPlugin from ansible.module_utils.common.text.converters import to_text from ansible.module_utils.ansible_release import __version__ as ansible_version from ansible.module_utils.six.moves.urllib.parse import urljoin -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe class InventoryModule(BaseInventoryPlugin): diff --git a/plugins/inventory/opennebula.py b/plugins/inventory/opennebula.py index 3babfa2324..bf81758ef1 100644 --- a/plugins/inventory/opennebula.py +++ b/plugins/inventory/opennebula.py @@ -97,7 +97,8 @@ except ImportError: from ansible.errors import AnsibleError from ansible.plugins.inventory import BaseInventoryPlugin, Constructable from ansible.module_utils.common.text.converters import to_native -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe from collections import namedtuple import os @@ -142,7 +143,8 @@ class InventoryModule(BaseInventoryPlugin, Constructable): nic = [nic] for net in nic: - return net['IP'] + if net.get('IP'): + return net['IP'] return False diff --git a/plugins/inventory/proxmox.py b/plugins/inventory/proxmox.py index ed55ef1b6a..edfadfd8ad 100644 --- a/plugins/inventory/proxmox.py +++ b/plugins/inventory/proxmox.py @@ -226,9 +226,9 @@ from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.six import string_types from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.utils.display import Display -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe from ansible_collections.community.general.plugins.module_utils.version import LooseVersion +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe # 3rd party imports try: @@ -329,8 +329,9 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): data = json['data'] break else: - # /hosts 's 'results' is a list of all hosts, returned is paginated - data = data + json['data'] + if json['data']: + # /hosts 's 'results' is a list of all hosts, returned is paginated + data = data + json['data'] break self._cache[self.cache_key][url] = data @@ -362,6 +363,34 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): except Exception: return None + def _get_lxc_interfaces(self, properties, node, vmid): + status_key = self._fact('status') + + if status_key not in properties or not properties[status_key] == 'running': + return + + ret = self._get_json("%s/api2/json/nodes/%s/lxc/%s/interfaces" % (self.proxmox_url, node, vmid), ignore_errors=[501]) + if not ret: + return + + result = [] + + for iface in ret: + result_iface = { + 'name': iface['name'], + 'hwaddr': iface['hwaddr'] + } + + if 'inet' in iface: + result_iface['inet'] = iface['inet'] + + if 'inet6' in iface: + result_iface['inet6'] = iface['inet6'] + + result.append(result_iface) + + properties[self._fact('lxc_interfaces')] = result + def _get_agent_network_interfaces(self, node, vmid, vmtype): result = [] @@ -526,6 +555,9 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): self._get_vm_config(properties, node, vmid, ittype, name) self._get_vm_snapshots(properties, node, vmid, ittype, name) + if ittype == 'lxc': + self._get_lxc_interfaces(properties, node, vmid) + # ensure the host satisfies filters if not self._can_add_host(name, properties): return None diff --git a/plugins/inventory/scaleway.py b/plugins/inventory/scaleway.py index 601129f566..4205caeca7 100644 --- a/plugins/inventory/scaleway.py +++ b/plugins/inventory/scaleway.py @@ -20,6 +20,7 @@ DOCUMENTATION = r''' plugin: description: Token that ensures this is a source file for the 'scaleway' plugin. required: true + type: string choices: ['scaleway', 'community.general.scaleway'] regions: description: Filter results on a specific Scaleway region. @@ -46,6 +47,7 @@ DOCUMENTATION = r''' - If not explicitly defined or in environment variables, it will try to lookup in the scaleway-cli configuration file (C($SCW_CONFIG_PATH), C($XDG_CONFIG_HOME/scw/config.yaml), or C(~/.config/scw/config.yaml)). - More details on L(how to generate token, https://www.scaleway.com/en/docs/generate-api-keys/). + type: string env: # in order of precedence - name: SCW_TOKEN @@ -121,10 +123,10 @@ else: from ansible.errors import AnsibleError from ansible.plugins.inventory import BaseInventoryPlugin, Constructable from ansible_collections.community.general.plugins.module_utils.scaleway import SCALEWAY_LOCATION, parse_pagination_link +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe from ansible.module_utils.urls import open_url from ansible.module_utils.common.text.converters import to_native, to_text from ansible.module_utils.six import raise_from -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe import ansible.module_utils.six.moves.urllib.parse as urllib_parse diff --git a/plugins/inventory/stackpath_compute.py b/plugins/inventory/stackpath_compute.py index 9a556d39e0..8508b4e797 100644 --- a/plugins/inventory/stackpath_compute.py +++ b/plugins/inventory/stackpath_compute.py @@ -24,6 +24,7 @@ DOCUMENTATION = ''' description: - A token that ensures this is a source file for the plugin. required: true + type: string choices: ['community.general.stackpath_compute'] client_id: description: @@ -72,7 +73,8 @@ from ansible.plugins.inventory import ( Cacheable ) from ansible.utils.display import Display -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe display = Display() diff --git a/plugins/inventory/virtualbox.py b/plugins/inventory/virtualbox.py index 8604808e15..d48c294fd9 100644 --- a/plugins/inventory/virtualbox.py +++ b/plugins/inventory/virtualbox.py @@ -14,12 +14,15 @@ DOCUMENTATION = ''' - Get inventory hosts from the local virtualbox installation. - Uses a YAML configuration file that ends with virtualbox.(yml|yaml) or vbox.(yml|yaml). - The inventory_hostname is always the 'Name' of the virtualbox instance. + - Groups can be assigned to the VMs using C(VBoxManage). Multiple groups can be assigned by using V(/) as a delimeter. + - A separate parameter, O(enable_advanced_group_parsing) is exposed to change grouping behaviour. See the parameter documentation for details. extends_documentation_fragment: - constructed - inventory_cache options: plugin: description: token that ensures this is a source file for the 'virtualbox' plugin + type: string required: true choices: ['virtualbox', 'community.general.virtualbox'] running_only: @@ -28,13 +31,28 @@ DOCUMENTATION = ''' default: false settings_password_file: description: provide a file containing the settings password (equivalent to --settingspwfile) + type: string network_info_path: description: property path to query for network information (ansible_host) + type: string default: "/VirtualBox/GuestInfo/Net/0/V4/IP" query: description: create vars from virtualbox properties type: dictionary default: {} + enable_advanced_group_parsing: + description: + - The default group parsing rule (when this setting is set to V(false)) is to split the VirtualBox VM's group based on the V(/) character and + assign the resulting list elements as an Ansible Group. + - Setting O(enable_advanced_group_parsing=true) changes this behaviour to match VirtualBox's interpretation of groups according to + U(https://www.virtualbox.org/manual/UserManual.html#gui-vmgroups). + Groups are now split using the V(,) character, and the V(/) character indicates nested groups. + - When enabled, a VM that's been configured using V(VBoxManage modifyvm "vm01" --groups "/TestGroup/TestGroup2,/TestGroup3") will result in + the group C(TestGroup2) being a child group of C(TestGroup); and + the VM being a part of C(TestGroup2) and C(TestGroup3). + default: false + type: bool + version_added: 9.2.0 ''' EXAMPLES = ''' @@ -62,7 +80,8 @@ from ansible.module_utils.common.text.converters import to_bytes, to_native, to_ from ansible.module_utils.common._collections_compat import MutableMapping from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable from ansible.module_utils.common.process import get_bin_path -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): @@ -176,14 +195,10 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): # found groups elif k == 'Groups': - for group in v.split('/'): - if group: - group = make_unsafe(group) - group = self.inventory.add_group(group) - self.inventory.add_child(group, current_host) - if group not in cacheable_results: - cacheable_results[group] = {'hosts': []} - cacheable_results[group]['hosts'].append(current_host) + if self.get_option('enable_advanced_group_parsing'): + self._handle_vboxmanage_group_string(v, current_host, cacheable_results) + else: + self._handle_group_string(v, current_host, cacheable_results) continue else: @@ -226,6 +241,64 @@ class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): return all(find_host(host, inventory)) + def _handle_group_string(self, vboxmanage_group, current_host, cacheable_results): + '''Handles parsing the VM's Group assignment from VBoxManage according to this inventory's initial implementation.''' + # The original implementation of this inventory plugin treated `/` as + # a delimeter to split and use as Ansible Groups. + for group in vboxmanage_group.split('/'): + if group: + group = make_unsafe(group) + group = self.inventory.add_group(group) + self.inventory.add_child(group, current_host) + if group not in cacheable_results: + cacheable_results[group] = {'hosts': []} + cacheable_results[group]['hosts'].append(current_host) + + def _handle_vboxmanage_group_string(self, vboxmanage_group, current_host, cacheable_results): + '''Handles parsing the VM's Group assignment from VBoxManage according to VirtualBox documentation.''' + # Per the VirtualBox documentation, a VM can be part of many groups, + # and it's possible to have nested groups. + # Many groups are separated by commas ",", and nested groups use + # slash "/". + # https://www.virtualbox.org/manual/UserManual.html#gui-vmgroups + # Multi groups: VBoxManage modifyvm "vm01" --groups "/TestGroup,/TestGroup2" + # Nested groups: VBoxManage modifyvm "vm01" --groups "/TestGroup/TestGroup2" + + for group in vboxmanage_group.split(','): + if not group: + # We could get an empty element due how to split works, and + # possible assignments from VirtualBox. e.g. ,/Group1 + continue + + if group == "/": + # This is the "root" group. We get here if the VM was not + # assigned to a particular group. Consider the host to be + # unassigned to a group. + continue + + parent_group = None + for subgroup in group.split('/'): + if not subgroup: + # Similarly to above, we could get an empty element. + # e.g //Group1 + continue + + if subgroup == '/': + # "root" group. + # Consider the host to be unassigned + continue + + subgroup = make_unsafe(subgroup) + subgroup = self.inventory.add_group(subgroup) + if parent_group is not None: + self.inventory.add_child(parent_group, subgroup) + self.inventory.add_child(subgroup, current_host) + if subgroup not in cacheable_results: + cacheable_results[subgroup] = {'hosts': []} + cacheable_results[subgroup]['hosts'].append(current_host) + + parent_group = subgroup + def verify_file(self, path): valid = False diff --git a/plugins/inventory/xen_orchestra.py b/plugins/inventory/xen_orchestra.py index 96dd997701..4094af2468 100644 --- a/plugins/inventory/xen_orchestra.py +++ b/plugins/inventory/xen_orchestra.py @@ -82,9 +82,9 @@ from time import sleep from ansible.errors import AnsibleError from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable -from ansible.utils.unsafe_proxy import wrap_var as make_unsafe from ansible_collections.community.general.plugins.module_utils.version import LooseVersion +from ansible_collections.community.general.plugins.plugin_utils.unsafe import make_unsafe # 3rd party imports try: diff --git a/plugins/lookup/bitwarden.py b/plugins/lookup/bitwarden.py index 2cb2d19a18..5e31cc6f89 100644 --- a/plugins/lookup/bitwarden.py +++ b/plugins/lookup/bitwarden.py @@ -29,7 +29,7 @@ DOCUMENTATION = """ - Field to retrieve, for example V(name) or V(id). - If set to V(id), only zero or one element can be returned. Use the Jinja C(first) filter to get the only list element. - - When O(collection_id) is set, this field can be undefined to retrieve the whole collection records. + - If set to V(None) or V(''), or if O(_terms) is empty, records are not filtered by fields. type: str default: name version_added: 5.7.0 @@ -40,6 +40,10 @@ DOCUMENTATION = """ description: Collection ID to filter results by collection. Leave unset to skip filtering. type: str version_added: 6.3.0 + organization_id: + description: Organization ID to filter results by organization. Leave unset to skip filtering. + type: str + version_added: 8.5.0 bw_session: description: Pass session key instead of reading from env. type: str @@ -142,45 +146,45 @@ class Bitwarden(object): raise BitwardenException(err) return to_text(out, errors='surrogate_or_strict'), to_text(err, errors='surrogate_or_strict') - def _get_matches(self, search_value, search_field, collection_id=None): + def _get_matches(self, search_value, search_field, collection_id=None, organization_id=None): """Return matching records whose search_field is equal to key. """ # Prepare set of params for Bitwarden CLI - if search_value: - if search_field == 'id': - params = ['get', 'item', search_value] - else: - params = ['list', 'items', '--search', search_value] - if collection_id: - params.extend(['--collectionid', collection_id]) + if search_field == 'id': + params = ['get', 'item', search_value] else: - if not collection_id: - raise AnsibleError("search_value is required if collection_id is not set.") + params = ['list', 'items'] + if search_value: + params.extend(['--search', search_value]) - params = ['list', 'items', '--collectionid', collection_id] + if collection_id: + params.extend(['--collectionid', collection_id]) + if organization_id: + params.extend(['--organizationid', organization_id]) out, err = self._run(params) # This includes things that matched in different fields. initial_matches = AnsibleJSONDecoder().raw_decode(out)[0] - if search_field == 'id' or not search_value: + if search_field == 'id': if initial_matches is None: initial_matches = [] else: initial_matches = [initial_matches] - # Filter to only include results from the right field. - return [item for item in initial_matches if item[search_field] == search_value] + # Filter to only include results from the right field, if a search is requested by value or field + return [item for item in initial_matches + if not search_value or not search_field or item.get(search_field) == search_value] - def get_field(self, field, search_value=None, search_field="name", collection_id=None): + def get_field(self, field, search_value, search_field="name", collection_id=None, organization_id=None): """Return a list of the specified field for records whose search_field match search_value and filtered by collection if collection has been provided. If field is None, return the whole record for each match. """ - matches = self._get_matches(search_value, search_field, collection_id) + matches = self._get_matches(search_value, search_field, collection_id, organization_id) if not field: return matches field_matches = [] @@ -215,15 +219,16 @@ class LookupModule(LookupBase): field = self.get_option('field') search_field = self.get_option('search') collection_id = self.get_option('collection_id') + organization_id = self.get_option('organization_id') _bitwarden.session = self.get_option('bw_session') if not _bitwarden.unlocked: raise AnsibleError("Bitwarden Vault locked. Run 'bw unlock'.") if not terms: - return [_bitwarden.get_field(field, None, search_field, collection_id)] + terms = [None] - return [_bitwarden.get_field(field, term, search_field, collection_id) for term in terms] + return [_bitwarden.get_field(field, term, search_field, collection_id, organization_id) for term in terms] _bitwarden = Bitwarden() diff --git a/plugins/lookup/bitwarden_secrets_manager.py b/plugins/lookup/bitwarden_secrets_manager.py index 2d6706bee1..8cabc693ff 100644 --- a/plugins/lookup/bitwarden_secrets_manager.py +++ b/plugins/lookup/bitwarden_secrets_manager.py @@ -70,6 +70,7 @@ RETURN = """ """ from subprocess import Popen, PIPE +from time import sleep from ansible.errors import AnsibleLookupError from ansible.module_utils.common.text.converters import to_text @@ -84,11 +85,29 @@ class BitwardenSecretsManagerException(AnsibleLookupError): class BitwardenSecretsManager(object): def __init__(self, path='bws'): self._cli_path = path + self._max_retries = 3 + self._retry_delay = 1 @property def cli_path(self): return self._cli_path + def _run_with_retry(self, args, stdin=None, retries=0): + out, err, rc = self._run(args, stdin) + + if rc != 0: + if retries >= self._max_retries: + raise BitwardenSecretsManagerException("Max retries exceeded. Unable to retrieve secret.") + + if "Too many requests" in err: + delay = self._retry_delay * (2 ** retries) + sleep(delay) + return self._run_with_retry(args, stdin, retries + 1) + else: + raise BitwardenSecretsManagerException(f"Command failed with return code {rc}: {err}") + + return out, err, rc + def _run(self, args, stdin=None): p = Popen([self.cli_path] + args, stdout=PIPE, stderr=PIPE, stdin=PIPE) out, err = p.communicate(stdin) @@ -107,7 +126,7 @@ class BitwardenSecretsManager(object): 'get', 'secret', secret_id ] - out, err, rc = self._run(params) + out, err, rc = self._run_with_retry(params) if rc != 0: raise BitwardenSecretsManagerException(to_text(err)) diff --git a/plugins/lookup/chef_databag.py b/plugins/lookup/chef_databag.py index b14d924ae8..a116b21e5f 100644 --- a/plugins/lookup/chef_databag.py +++ b/plugins/lookup/chef_databag.py @@ -22,10 +22,12 @@ DOCUMENTATION = ''' name: description: - Name of the databag + type: string required: true item: description: - Item to fetch + type: string required: true ''' diff --git a/plugins/lookup/consul_kv.py b/plugins/lookup/consul_kv.py index f8aadadc19..79eb65edb1 100644 --- a/plugins/lookup/consul_kv.py +++ b/plugins/lookup/consul_kv.py @@ -29,13 +29,17 @@ DOCUMENTATION = ''' index: description: - If the key has a value with the specified index then this is returned allowing access to historical values. + type: int datacenter: description: - Retrieve the key from a consul datacenter other than the default for the consul host. + type: str token: description: The acl token to allow access to restricted values. + type: str host: default: localhost + type: str description: - The target to connect to, must be a resolvable address. - Will be determined from E(ANSIBLE_CONSUL_URL) if that is set. @@ -46,22 +50,26 @@ DOCUMENTATION = ''' description: - The port of the target host to connect to. - If you use E(ANSIBLE_CONSUL_URL) this value will be used from there. + type: int default: 8500 scheme: default: http + type: str description: - Whether to use http or https. - If you use E(ANSIBLE_CONSUL_URL) this value will be used from there. validate_certs: default: true - description: Whether to verify the ssl connection or not. + description: Whether to verify the TLS connection or not. + type: bool env: - name: ANSIBLE_CONSUL_VALIDATE_CERTS ini: - section: lookup_consul key: validate_certs client_cert: - description: The client cert to verify the ssl connection. + description: The client cert to verify the TLS connection. + type: str env: - name: ANSIBLE_CONSUL_CLIENT_CERT ini: @@ -94,7 +102,7 @@ EXAMPLES = """ - name: retrieving a KV from a remote cluster on non default port ansible.builtin.debug: - msg: "{{ lookup('community.general.consul_kv', 'my/key', host='10.10.10.10', port='2000') }}" + msg: "{{ lookup('community.general.consul_kv', 'my/key', host='10.10.10.10', port=2000) }}" """ RETURN = """ diff --git a/plugins/lookup/credstash.py b/plugins/lookup/credstash.py index 6a3f58595b..fd284f55c8 100644 --- a/plugins/lookup/credstash.py +++ b/plugins/lookup/credstash.py @@ -120,10 +120,10 @@ class LookupModule(LookupBase): aws_secret_access_key = self.get_option('aws_secret_access_key') aws_session_token = self.get_option('aws_session_token') - context = dict( - (k, v) for k, v in kwargs.items() + context = { + k: v for k, v in kwargs.items() if k not in ('version', 'region', 'table', 'profile_name', 'aws_access_key_id', 'aws_secret_access_key', 'aws_session_token') - ) + } kwargs_pass = { 'profile_name': profile_name, diff --git a/plugins/lookup/cyberarkpassword.py b/plugins/lookup/cyberarkpassword.py index c3cc427df8..6a08675b3b 100644 --- a/plugins/lookup/cyberarkpassword.py +++ b/plugins/lookup/cyberarkpassword.py @@ -17,19 +17,23 @@ DOCUMENTATION = ''' options : _command: description: Cyberark CLI utility. + type: string env: - name: AIM_CLIPASSWORDSDK_CMD default: '/opt/CARKaim/sdk/clipasswordsdk' appid: description: Defines the unique ID of the application that is issuing the password request. + type: string required: true query: description: Describes the filter criteria for the password retrieval. + type: string required: true output: description: - Specifies the desired output fields separated by commas. - "They could be: Password, PassProps., PasswordChangeInProcess" + type: string default: 'password' _extra: description: for extra_params values please check parameters for clipasswordsdk in CyberArk's "Credential Provider and ASCP Implementation Guide" diff --git a/plugins/lookup/dsv.py b/plugins/lookup/dsv.py index 2dbb7db3ea..5e26c43af4 100644 --- a/plugins/lookup/dsv.py +++ b/plugins/lookup/dsv.py @@ -22,6 +22,7 @@ options: required: true tenant: description: The first format parameter in the default O(url_template). + type: string env: - name: DSV_TENANT ini: @@ -32,6 +33,7 @@ options: default: com description: The top-level domain of the tenant; the second format parameter in the default O(url_template). + type: string env: - name: DSV_TLD ini: @@ -40,6 +42,7 @@ options: required: false client_id: description: The client_id with which to request the Access Grant. + type: string env: - name: DSV_CLIENT_ID ini: @@ -48,6 +51,7 @@ options: required: true client_secret: description: The client secret associated with the specific O(client_id). + type: string env: - name: DSV_CLIENT_SECRET ini: @@ -58,6 +62,7 @@ options: default: https://{}.secretsvaultcloud.{}/v1 description: The path to prepend to the base URL to form a valid REST API request. + type: string env: - name: DSV_URL_TEMPLATE ini: diff --git a/plugins/lookup/etcd.py b/plugins/lookup/etcd.py index 5135e74877..1dec890b20 100644 --- a/plugins/lookup/etcd.py +++ b/plugins/lookup/etcd.py @@ -25,12 +25,14 @@ DOCUMENTATION = ''' url: description: - Environment variable with the URL for the etcd server + type: string default: 'http://127.0.0.1:4001' env: - name: ANSIBLE_ETCD_URL version: description: - Environment variable with the etcd protocol version + type: string default: 'v1' env: - name: ANSIBLE_ETCD_VERSION diff --git a/plugins/lookup/filetree.py b/plugins/lookup/filetree.py index 2131de99a5..ee7bfe27b7 100644 --- a/plugins/lookup/filetree.py +++ b/plugins/lookup/filetree.py @@ -17,8 +17,10 @@ description: This enables merging different trees in order of importance, or add role_vars to specific paths to influence different instances of the same role. options: _terms: - description: path(s) of files to read + description: Path(s) of files to read. required: true + type: list + elements: string ''' EXAMPLES = r""" diff --git a/plugins/lookup/hiera.py b/plugins/lookup/hiera.py index fa4d0a1999..02669c98dc 100644 --- a/plugins/lookup/hiera.py +++ b/plugins/lookup/hiera.py @@ -25,12 +25,14 @@ DOCUMENTATION = ''' executable: description: - Binary file to execute Hiera. + type: string default: '/usr/bin/hiera' env: - name: ANSIBLE_HIERA_BIN config_file: description: - File that describes the hierarchy of Hiera. + type: string default: '/etc/hiera.yaml' env: - name: ANSIBLE_HIERA_CFG diff --git a/plugins/lookup/merge_variables.py b/plugins/lookup/merge_variables.py index 4fc33014c0..6287914747 100644 --- a/plugins/lookup/merge_variables.py +++ b/plugins/lookup/merge_variables.py @@ -12,7 +12,7 @@ DOCUMENTATION = """ - Mark Ettema (@m-a-r-k-e) - Alexander Petrenz (@alpex8) name: merge_variables - short_description: merge variables with a certain suffix + short_description: merge variables whose names match a given pattern description: - This lookup returns the merged result of all variables in scope that match the given prefixes, suffixes, or regular expressions, optionally. @@ -157,7 +157,9 @@ class LookupModule(LookupBase): cross_host_merge_result = initial_value for host in variables["hostvars"]: if self._is_host_in_allowed_groups(variables["hostvars"][host]["group_names"]): - cross_host_merge_result = self._merge_vars(term, cross_host_merge_result, variables["hostvars"][host]) + host_variables = dict(variables["hostvars"].raw_get(host)) + host_variables["hostvars"] = variables["hostvars"] # re-add hostvars + cross_host_merge_result = self._merge_vars(term, cross_host_merge_result, host_variables) ret.append(cross_host_merge_result) return ret @@ -195,7 +197,8 @@ class LookupModule(LookupBase): result = initial_value for var_name in var_merge_names: - var_value = self._templar.template(variables[var_name]) # Render jinja2 templates + with self._templar.set_temporary_context(available_variables=variables): # tmp. switch renderer to context of current variables + var_value = self._templar.template(variables[var_name]) # Render jinja2 templates var_type = _verify_and_get_type(var_value) if prev_var_type is None: diff --git a/plugins/lookup/onepassword.py b/plugins/lookup/onepassword.py index 8ca95de0bc..921cf9acb8 100644 --- a/plugins/lookup/onepassword.py +++ b/plugins/lookup/onepassword.py @@ -23,6 +23,8 @@ DOCUMENTATION = ''' _terms: description: Identifier(s) (case-insensitive UUID or name) of item(s) to retrieve. required: true + type: list + elements: string account_id: version_added: 7.5.0 domain: @@ -133,7 +135,7 @@ class OnePassCLIBase(with_metaclass(abc.ABCMeta, object)): self._version = None def _check_required_params(self, required_params): - non_empty_attrs = dict((param, getattr(self, param, None)) for param in required_params if getattr(self, param, None)) + non_empty_attrs = {param: getattr(self, param) for param in required_params if getattr(self, param, None)} missing = set(required_params).difference(non_empty_attrs) if missing: prefix = "Unable to sign in to 1Password. Missing required parameter" diff --git a/plugins/lookup/onepassword_doc.py b/plugins/lookup/onepassword_doc.py index ab24795df2..789e51c35a 100644 --- a/plugins/lookup/onepassword_doc.py +++ b/plugins/lookup/onepassword_doc.py @@ -24,6 +24,8 @@ DOCUMENTATION = ''' _terms: description: Identifier(s) (case-insensitive UUID or name) of item(s) to retrieve. required: true + type: list + elements: string extends_documentation_fragment: - community.general.onepassword diff --git a/plugins/lookup/onepassword_raw.py b/plugins/lookup/onepassword_raw.py index 3eef535a1c..dc3e590329 100644 --- a/plugins/lookup/onepassword_raw.py +++ b/plugins/lookup/onepassword_raw.py @@ -23,6 +23,8 @@ DOCUMENTATION = ''' _terms: description: Identifier(s) (case-insensitive UUID or name) of item(s) to retrieve. required: true + type: list + elements: string account_id: version_added: 7.5.0 domain: diff --git a/plugins/lookup/passwordstore.py b/plugins/lookup/passwordstore.py index 7a6fca7a01..510bdbec3d 100644 --- a/plugins/lookup/passwordstore.py +++ b/plugins/lookup/passwordstore.py @@ -42,8 +42,9 @@ DOCUMENTATION = ''' default: false umask: description: - - Sets the umask for the created .gpg files. The first octed must be greater than 3 (user readable). + - Sets the umask for the created V(.gpg) files. The first octed must be greater than 3 (user readable). - Note pass' default value is V('077'). + type: string env: - name: PASSWORD_STORE_UMASK version_added: 1.3.0 @@ -139,6 +140,21 @@ DOCUMENTATION = ''' type: bool default: true version_added: 8.1.0 + missing_subkey: + description: + - Preference about what to do if the password subkey is missing. + - If set to V(error), the lookup will error out if the subkey does not exist. + - If set to V(empty) or V(warn), will return a V(none) in case the subkey does not exist. + version_added: 8.6.0 + type: str + default: empty + choices: + - error + - warn + - empty + ini: + - section: passwordstore_lookup + key: missing_subkey notes: - The lookup supports passing all options as lookup parameters since community.general 6.0.0. ''' @@ -147,6 +163,7 @@ ansible.cfg: | [passwordstore_lookup] lock=readwrite locktimeout=45s + missing_subkey=warn tasks.yml: | --- @@ -432,13 +449,28 @@ class LookupModule(LookupBase): if self.paramvals['subkey'] in self.passdict: return self.passdict[self.paramvals['subkey']] else: + if self.paramvals["missing_subkey"] == "error": + raise AnsibleError( + "passwordstore: subkey {0} for passname {1} not found and missing_subkey=error is set".format( + self.paramvals["subkey"], self.passname + ) + ) + + if self.paramvals["missing_subkey"] == "warn": + display.warning( + "passwordstore: subkey {0} for passname {1} not found".format( + self.paramvals["subkey"], self.passname + ) + ) + return None @contextmanager def opt_lock(self, type): if self.get_option('lock') == type: tmpdir = os.environ.get('TMPDIR', '/tmp') - lockfile = os.path.join(tmpdir, '.passwordstore.lock') + user = os.environ.get('USER') + lockfile = os.path.join(tmpdir, '.{0}.passwordstore.lock'.format(user)) with FileLock().lock_file(lockfile, tmpdir, self.lock_timeout): self.locked = type yield @@ -481,6 +513,7 @@ class LookupModule(LookupBase): 'umask': self.get_option('umask'), 'timestamp': self.get_option('timestamp'), 'preserve': self.get_option('preserve'), + "missing_subkey": self.get_option("missing_subkey"), } def run(self, terms, variables, **kwargs): diff --git a/plugins/lookup/random_string.py b/plugins/lookup/random_string.py index d3b29629d7..9b811dd8b3 100644 --- a/plugins/lookup/random_string.py +++ b/plugins/lookup/random_string.py @@ -104,37 +104,37 @@ EXAMPLES = r""" - name: Generate random string ansible.builtin.debug: var: lookup('community.general.random_string') - # Example result: ['DeadBeeF'] + # Example result: 'DeadBeeF' - name: Generate random string with length 12 ansible.builtin.debug: var: lookup('community.general.random_string', length=12) - # Example result: ['Uan0hUiX5kVG'] + # Example result: 'Uan0hUiX5kVG' - name: Generate base64 encoded random string ansible.builtin.debug: var: lookup('community.general.random_string', base64=True) - # Example result: ['NHZ6eWN5Qk0='] + # Example result: 'NHZ6eWN5Qk0=' - name: Generate a random string with 1 lower, 1 upper, 1 number and 1 special char (at least) ansible.builtin.debug: var: lookup('community.general.random_string', min_lower=1, min_upper=1, min_special=1, min_numeric=1) - # Example result: ['&Qw2|E[-'] + # Example result: '&Qw2|E[-' - name: Generate a random string with all lower case characters - debug: + ansible.builtin.debug: var: query('community.general.random_string', upper=false, numbers=false, special=false) # Example result: ['exolxzyz'] - name: Generate random hexadecimal string - debug: + ansible.builtin.debug: var: query('community.general.random_string', upper=false, lower=false, override_special=hex_chars, numbers=false) vars: hex_chars: '0123456789ABCDEF' # Example result: ['D2A40737'] - name: Generate random hexadecimal string with override_all - debug: + ansible.builtin.debug: var: query('community.general.random_string', override_all=hex_chars) vars: hex_chars: '0123456789ABCDEF' diff --git a/plugins/lookup/redis.py b/plugins/lookup/redis.py index 43b046a798..17cbf120e9 100644 --- a/plugins/lookup/redis.py +++ b/plugins/lookup/redis.py @@ -19,8 +19,11 @@ DOCUMENTATION = ''' options: _terms: description: list of keys to query + type: list + elements: string host: description: location of Redis host + type: string default: '127.0.0.1' env: - name: ANSIBLE_REDIS_HOST diff --git a/plugins/lookup/shelvefile.py b/plugins/lookup/shelvefile.py index 35f1097c8b..70d18338e9 100644 --- a/plugins/lookup/shelvefile.py +++ b/plugins/lookup/shelvefile.py @@ -15,11 +15,15 @@ DOCUMENTATION = ''' options: _terms: description: Sets of key value pairs of parameters. + type: list + elements: str key: description: Key to query. + type: str required: true file: description: Path to shelve file. + type: path required: true ''' diff --git a/plugins/lookup/tss.py b/plugins/lookup/tss.py index 80105ff715..f2d79ed168 100644 --- a/plugins/lookup/tss.py +++ b/plugins/lookup/tss.py @@ -25,7 +25,8 @@ options: _terms: description: The integer ID of the secret. required: true - type: int + type: list + elements: int secret_path: description: Indicate a full path of secret including folder and secret name when the secret ID is set to 0. required: false @@ -52,6 +53,7 @@ options: version_added: 7.0.0 base_url: description: The base URL of the server, for example V(https://localhost/SecretServer). + type: string env: - name: TSS_BASE_URL ini: @@ -60,6 +62,7 @@ options: required: true username: description: The username with which to request the OAuth2 Access Grant. + type: string env: - name: TSS_USERNAME ini: @@ -69,6 +72,7 @@ options: description: - The password associated with the supplied username. - Required when O(token) is not provided. + type: string env: - name: TSS_PASSWORD ini: @@ -80,6 +84,7 @@ options: - The domain with which to request the OAuth2 Access Grant. - Optional when O(token) is not provided. - Requires C(python-tss-sdk) version 1.0.0 or greater. + type: string env: - name: TSS_DOMAIN ini: @@ -92,6 +97,7 @@ options: - Existing token for Thycotic authorizer. - If provided, O(username) and O(password) are not needed. - Requires C(python-tss-sdk) version 1.0.0 or greater. + type: string env: - name: TSS_TOKEN ini: @@ -102,6 +108,7 @@ options: default: /api/v1 description: The path to append to the base URL to form a valid REST API request. + type: string env: - name: TSS_API_PATH_URI required: false @@ -109,6 +116,7 @@ options: default: /oauth2/token description: The path to append to the base URL to form a valid OAuth2 Access Grant request. + type: string env: - name: TSS_TOKEN_PATH_URI required: false diff --git a/plugins/module_utils/cmd_runner.py b/plugins/module_utils/cmd_runner.py index 8649871207..95167a282d 100644 --- a/plugins/module_utils/cmd_runner.py +++ b/plugins/module_utils/cmd_runner.py @@ -11,6 +11,7 @@ from functools import wraps from ansible.module_utils.common.collections import is_sequence from ansible.module_utils.six import iteritems +from ansible.module_utils.common.locale import get_best_parsable_locale def _ensure_list(value): @@ -89,18 +90,31 @@ class FormatError(CmdRunnerException): class _ArgFormat(object): + # DEPRECATION: set default value for ignore_none to True in community.general 12.0.0 def __init__(self, func, ignore_none=None, ignore_missing_value=False): self.func = func self.ignore_none = ignore_none self.ignore_missing_value = ignore_missing_value - def __call__(self, value, ctx_ignore_none): + # DEPRECATION: remove parameter ctx_ignore_none in community.general 12.0.0 + def __call__(self, value, ctx_ignore_none=True): + # DEPRECATION: replace ctx_ignore_none with True in community.general 12.0.0 ignore_none = self.ignore_none if self.ignore_none is not None else ctx_ignore_none if value is None and ignore_none: return [] f = self.func return [str(x) for x in f(value)] + def __str__(self): + return "".format( + self.func, + self.ignore_none, + self.ignore_missing_value, + ) + + def __repr__(self): + return str(self) + class _Format(object): @staticmethod @@ -114,7 +128,7 @@ class _Format(object): @staticmethod def as_bool_not(args): - return _ArgFormat(lambda value: [] if value else _ensure_list(args), ignore_none=False) + return _Format.as_bool([], args, ignore_none=False) @staticmethod def as_optval(arg, ignore_none=None): @@ -129,8 +143,15 @@ class _Format(object): return _ArgFormat(lambda value: ["{0}={1}".format(arg, value)], ignore_none=ignore_none) @staticmethod - def as_list(ignore_none=None): - return _ArgFormat(_ensure_list, ignore_none=ignore_none) + def as_list(ignore_none=None, min_len=0, max_len=None): + def func(value): + value = _ensure_list(value) + if len(value) < min_len: + raise ValueError("Parameter must have at least {0} element(s)".format(min_len)) + if max_len is not None and len(value) > max_len: + raise ValueError("Parameter must have at most {0} element(s)".format(max_len)) + return value + return _ArgFormat(func, ignore_none=ignore_none) @staticmethod def as_fixed(args): @@ -177,6 +198,19 @@ class _Format(object): return func(**v) return wrapper + @staticmethod + def stack(fmt): + @wraps(fmt) + def wrapper(*args, **kwargs): + new_func = fmt(ignore_none=True, *args, **kwargs) + + def stacking(value): + stack = [new_func(v) for v in value if v] + stack = [x for args in stack for x in args] + return stack + return _ArgFormat(stacking, ignore_none=True) + return wrapper + class CmdRunner(object): """ @@ -197,9 +231,19 @@ class CmdRunner(object): self.default_args_order = self._prepare_args_order(default_args_order) if arg_formats is None: arg_formats = {} - self.arg_formats = dict(arg_formats) + self.arg_formats = {} + for fmt_name, fmt in arg_formats.items(): + if not isinstance(fmt, _ArgFormat): + fmt = _Format.as_func(func=fmt, ignore_none=True) + self.arg_formats[fmt_name] = fmt self.check_rc = check_rc - self.force_lang = force_lang + if force_lang == "auto": + try: + self.force_lang = get_best_parsable_locale() + except RuntimeWarning: + self.force_lang = "C" + else: + self.force_lang = force_lang self.path_prefix = path_prefix if environ_update is None: environ_update = {} @@ -216,7 +260,16 @@ class CmdRunner(object): def binary(self): return self.command[0] - def __call__(self, args_order=None, output_process=None, ignore_value_none=True, check_mode_skip=False, check_mode_return=None, **kwargs): + # remove parameter ignore_value_none in community.general 12.0.0 + def __call__(self, args_order=None, output_process=None, ignore_value_none=None, check_mode_skip=False, check_mode_return=None, **kwargs): + if ignore_value_none is None: + ignore_value_none = True + else: + self.module.deprecate( + "Using ignore_value_none when creating the runner context is now deprecated, " + "and the parameter will be removed in community.general 12.0.0. ", + version="12.0.0", collection_name="community.general" + ) if output_process is None: output_process = _process_as_is if args_order is None: @@ -228,7 +281,7 @@ class CmdRunner(object): return _CmdRunnerContext(runner=self, args_order=args_order, output_process=output_process, - ignore_value_none=ignore_value_none, + ignore_value_none=ignore_value_none, # DEPRECATION: remove in community.general 12.0.0 check_mode_skip=check_mode_skip, check_mode_return=check_mode_return, **kwargs) @@ -244,6 +297,7 @@ class _CmdRunnerContext(object): self.runner = runner self.args_order = tuple(args_order) self.output_process = output_process + # DEPRECATION: parameter ignore_value_none at the context level is deprecated and will be removed in community.general 12.0.0 self.ignore_value_none = ignore_value_none self.check_mode_skip = check_mode_skip self.check_mode_return = check_mode_return @@ -283,6 +337,7 @@ class _CmdRunnerContext(object): value = named_args[arg_name] elif not runner.arg_formats[arg_name].ignore_missing_value: raise MissingArgumentValue(self.args_order, arg_name) + # DEPRECATION: remove parameter ctx_ignore_none in 12.0.0 self.cmd.extend(runner.arg_formats[arg_name](value, ctx_ignore_none=self.ignore_value_none)) except MissingArgumentValue: raise @@ -299,7 +354,7 @@ class _CmdRunnerContext(object): @property def run_info(self): return dict( - ignore_value_none=self.ignore_value_none, + ignore_value_none=self.ignore_value_none, # DEPRECATION: remove in community.general 12.0.0 check_rc=self.check_rc, environ_update=self.environ_update, args_order=self.args_order, diff --git a/plugins/module_utils/consul.py b/plugins/module_utils/consul.py index 68c1a130b4..cd54a105f8 100644 --- a/plugins/module_utils/consul.py +++ b/plugins/module_utils/consul.py @@ -10,6 +10,7 @@ __metaclass__ = type import copy import json +import re from ansible.module_utils.six.moves.urllib import error as urllib_error from ansible.module_utils.six.moves.urllib.parse import urlencode @@ -68,6 +69,25 @@ def camel_case_key(key): return "".join(parts) +def validate_check(check): + validate_duration_keys = ['Interval', 'Ttl', 'Timeout'] + validate_tcp_regex = r"(?P.*):(?P(?:[0-9]+))$" + if check.get('Tcp') is not None: + match = re.match(validate_tcp_regex, check['Tcp']) + if not match: + raise Exception('tcp check must be in host:port format') + for duration in validate_duration_keys: + if duration in check and check[duration] is not None: + check[duration] = validate_duration(check[duration]) + + +def validate_duration(duration): + if duration: + if not re.search(r"\d+(?:ns|us|ms|s|m|h)", duration): + duration = "{0}s".format(duration) + return duration + + STATE_PARAMETER = "state" STATE_PRESENT = "present" STATE_ABSENT = "absent" @@ -81,7 +101,7 @@ OPERATION_DELETE = "remove" def _normalize_params(params, arg_spec): final_params = {} for k, v in params.items(): - if k not in arg_spec: # Alias + if k not in arg_spec or v is None: # Alias continue spec = arg_spec[k] if ( @@ -105,9 +125,10 @@ class _ConsulModule: """ api_endpoint = None # type: str - unique_identifier = None # type: str + unique_identifiers = None # type: list result_key = None # type: str create_only_fields = set() + operational_attributes = set() params = {} def __init__(self, module): @@ -119,6 +140,8 @@ class _ConsulModule: if k not in STATE_PARAMETER and k not in AUTH_ARGUMENTS_SPEC } + self.operational_attributes.update({"CreateIndex", "CreateTime", "Hash", "ModifyIndex"}) + def execute(self): obj = self.read_object() @@ -203,14 +226,24 @@ class _ConsulModule: return False def prepare_object(self, existing, obj): - operational_attributes = {"CreateIndex", "CreateTime", "Hash", "ModifyIndex"} existing = { - k: v for k, v in existing.items() if k not in operational_attributes + k: v for k, v in existing.items() if k not in self.operational_attributes } for k, v in obj.items(): existing[k] = v return existing + def id_from_obj(self, obj, camel_case=False): + def key_func(key): + return camel_case_key(key) if camel_case else key + + if self.unique_identifiers: + for identifier in self.unique_identifiers: + identifier = key_func(identifier) + if identifier in obj: + return obj[identifier] + return None + def endpoint_url(self, operation, identifier=None): if operation == OPERATION_CREATE: return self.api_endpoint @@ -219,7 +252,8 @@ class _ConsulModule: raise RuntimeError("invalid arguments passed") def read_object(self): - url = self.endpoint_url(OPERATION_READ, self.params.get(self.unique_identifier)) + identifier = self.id_from_obj(self.params) + url = self.endpoint_url(OPERATION_READ, identifier) try: return self.get(url) except RequestError as e: @@ -233,25 +267,28 @@ class _ConsulModule: if self._module.check_mode: return obj else: - return self.put(self.api_endpoint, data=self.prepare_object({}, obj)) + url = self.endpoint_url(OPERATION_CREATE) + created_obj = self.put(url, data=self.prepare_object({}, obj)) + if created_obj is None: + created_obj = self.read_object() + return created_obj def update_object(self, existing, obj): - url = self.endpoint_url( - OPERATION_UPDATE, existing.get(camel_case_key(self.unique_identifier)) - ) merged_object = self.prepare_object(existing, obj) if self._module.check_mode: return merged_object else: - return self.put(url, data=merged_object) + url = self.endpoint_url(OPERATION_UPDATE, self.id_from_obj(existing, camel_case=True)) + updated_obj = self.put(url, data=merged_object) + if updated_obj is None: + updated_obj = self.read_object() + return updated_obj def delete_object(self, obj): if self._module.check_mode: return {} else: - url = self.endpoint_url( - OPERATION_DELETE, obj.get(camel_case_key(self.unique_identifier)) - ) + url = self.endpoint_url(OPERATION_DELETE, self.id_from_obj(obj, camel_case=True)) return self.delete(url) def _request(self, method, url_parts, data=None, params=None): @@ -309,7 +346,9 @@ class _ConsulModule: if 400 <= status < 600: raise RequestError(status, response_data) - return json.loads(response_data) + if response_data: + return json.loads(response_data) + return None def get(self, url_parts, **kwargs): return self._request("GET", url_parts, **kwargs) diff --git a/plugins/module_utils/csv.py b/plugins/module_utils/csv.py index 200548a46d..46408e4877 100644 --- a/plugins/module_utils/csv.py +++ b/plugins/module_utils/csv.py @@ -43,7 +43,7 @@ def initialize_dialect(dialect, **kwargs): raise DialectNotAvailableError("Dialect '%s' is not supported by your version of python." % dialect) # Create a dictionary from only set options - dialect_params = dict((k, v) for k, v in kwargs.items() if v is not None) + dialect_params = {k: v for k, v in kwargs.items() if v is not None} if dialect_params: try: csv.register_dialect('custom', dialect, **dialect_params) diff --git a/plugins/module_utils/datetime.py b/plugins/module_utils/datetime.py new file mode 100644 index 0000000000..c7899f68da --- /dev/null +++ b/plugins/module_utils/datetime.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2023 Felix Fontein +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import datetime as _datetime +import sys + + +_USE_TIMEZONE = sys.version_info >= (3, 6) + + +def ensure_timezone_info(value): + if not _USE_TIMEZONE or value.tzinfo is not None: + return value + return value.astimezone(_datetime.timezone.utc) + + +def fromtimestamp(value): + if _USE_TIMEZONE: + return _datetime.fromtimestamp(value, tz=_datetime.timezone.utc) + return _datetime.utcfromtimestamp(value) + + +def now(): + if _USE_TIMEZONE: + return _datetime.datetime.now(tz=_datetime.timezone.utc) + return _datetime.datetime.utcnow() diff --git a/plugins/module_utils/django.py b/plugins/module_utils/django.py new file mode 100644 index 0000000000..5fb375c6fd --- /dev/null +++ b/plugins/module_utils/django.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible_collections.community.general.plugins.module_utils.cmd_runner import cmd_runner_fmt +from ansible_collections.community.general.plugins.module_utils.python_runner import PythonRunner +from ansible_collections.community.general.plugins.module_utils.module_helper import ModuleHelper + + +django_std_args = dict( + # environmental options + venv=dict(type="path"), + # default options of django-admin + settings=dict(type="str", required=True), + pythonpath=dict(type="path"), + traceback=dict(type="bool"), + verbosity=dict(type="int", choices=[0, 1, 2, 3]), + skip_checks=dict(type="bool"), +) + +_django_std_arg_fmts = dict( + command=cmd_runner_fmt.as_list(), + settings=cmd_runner_fmt.as_opt_eq_val("--settings"), + pythonpath=cmd_runner_fmt.as_opt_eq_val("--pythonpath"), + traceback=cmd_runner_fmt.as_bool("--traceback"), + verbosity=cmd_runner_fmt.as_opt_val("--verbosity"), + no_color=cmd_runner_fmt.as_fixed("--no-color"), + skip_checks=cmd_runner_fmt.as_bool("--skip-checks"), +) + +_django_database_args = dict( + database=dict(type="str", default="default"), +) + +_args_menu = dict( + std=(django_std_args, _django_std_arg_fmts), + database=(_django_database_args, {"database": cmd_runner_fmt.as_opt_eq_val("--database")}), + noinput=({}, {"noinput": cmd_runner_fmt.as_fixed("--noinput")}), + dry_run=({}, {"dry_run": cmd_runner_fmt.as_bool("--dry-run")}), + check=({}, {"check": cmd_runner_fmt.as_bool("--check")}), +) + + +class _DjangoRunner(PythonRunner): + def __init__(self, module, arg_formats=None, **kwargs): + arg_fmts = dict(arg_formats) if arg_formats else {} + arg_fmts.update(_django_std_arg_fmts) + + super(_DjangoRunner, self).__init__(module, ["-m", "django"], arg_formats=arg_fmts, **kwargs) + + def __call__(self, output_process=None, ignore_value_none=True, check_mode_skip=False, check_mode_return=None, **kwargs): + args_order = ( + ("command", "no_color", "settings", "pythonpath", "traceback", "verbosity", "skip_checks") + self._prepare_args_order(self.default_args_order) + ) + return super(_DjangoRunner, self).__call__(args_order, output_process, ignore_value_none, check_mode_skip, check_mode_return, **kwargs) + + +class DjangoModuleHelper(ModuleHelper): + module = {} + use_old_vardict = False + django_admin_cmd = None + arg_formats = {} + django_admin_arg_order = () + use_old_vardict = False + _django_args = [] + _check_mode_arg = "" + + def __init__(self): + self.module["argument_spec"], self.arg_formats = self._build_args(self.module.get("argument_spec", {}), + self.arg_formats, + *(["std"] + self._django_args)) + super(DjangoModuleHelper, self).__init__(self.module) + if self.django_admin_cmd is not None: + self.vars.command = self.django_admin_cmd + + @staticmethod + def _build_args(arg_spec, arg_format, *names): + res_arg_spec = {} + res_arg_fmts = {} + for name in names: + args, fmts = _args_menu[name] + res_arg_spec = dict_merge(res_arg_spec, args) + res_arg_fmts = dict_merge(res_arg_fmts, fmts) + res_arg_spec = dict_merge(res_arg_spec, arg_spec) + res_arg_fmts = dict_merge(res_arg_fmts, arg_format) + + return res_arg_spec, res_arg_fmts + + def __run__(self): + runner = _DjangoRunner(self.module, + default_args_order=self.django_admin_arg_order, + arg_formats=self.arg_formats, + venv=self.vars.venv, + check_rc=True) + with runner() as ctx: + run_params = self.vars.as_dict() + if self._check_mode_arg: + run_params.update({self._check_mode_arg: self.check_mode}) + results = ctx.run(**run_params) + self.vars.stdout = ctx.results_out + self.vars.stderr = ctx.results_err + self.vars.cmd = ctx.cmd + if self.verbosity >= 3: + self.vars.run_info = ctx.run_info + + return results + + @classmethod + def execute(cls): + cls().run() diff --git a/plugins/module_utils/gandi_livedns_api.py b/plugins/module_utils/gandi_livedns_api.py index 53245d44d0..824fea46e7 100644 --- a/plugins/module_utils/gandi_livedns_api.py +++ b/plugins/module_utils/gandi_livedns_api.py @@ -33,6 +33,7 @@ class GandiLiveDNSAPI(object): def __init__(self, module): self.module = module self.api_key = module.params['api_key'] + self.personal_access_token = module.params['personal_access_token'] def _build_error_message(self, module, info): s = '' @@ -50,7 +51,12 @@ class GandiLiveDNSAPI(object): return s def _gandi_api_call(self, api_call, method='GET', payload=None, error_on_404=True): - headers = {'Authorization': 'Apikey {0}'.format(self.api_key), + authorization_header = ( + 'Bearer {0}'.format(self.personal_access_token) + if self.personal_access_token + else 'Apikey {0}'.format(self.api_key) + ) + headers = {'Authorization': authorization_header, 'Content-Type': 'application/json'} data = None if payload: diff --git a/plugins/module_utils/gitlab.py b/plugins/module_utils/gitlab.py index b1354d8a9d..3c0014cfe9 100644 --- a/plugins/module_utils/gitlab.py +++ b/plugins/module_utils/gitlab.py @@ -111,24 +111,16 @@ def gitlab_authentication(module, min_version=None): verify = ca_path if validate_certs and ca_path else validate_certs try: - # python-gitlab library remove support for username/password authentication since 1.13.0 - # Changelog : https://github.com/python-gitlab/python-gitlab/releases/tag/v1.13.0 - # This condition allow to still support older version of the python-gitlab library - if LooseVersion(gitlab.__version__) < LooseVersion("1.13.0"): - gitlab_instance = gitlab.Gitlab(url=gitlab_url, ssl_verify=verify, email=gitlab_user, password=gitlab_password, - private_token=gitlab_token, api_version=4) - else: - # We can create an oauth_token using a username and password - # https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow - if gitlab_user: - data = {'grant_type': 'password', 'username': gitlab_user, 'password': gitlab_password} - resp = requests.post(urljoin(gitlab_url, "oauth/token"), data=data, verify=verify) - resp_data = resp.json() - gitlab_oauth_token = resp_data["access_token"] - - gitlab_instance = gitlab.Gitlab(url=gitlab_url, ssl_verify=verify, private_token=gitlab_token, - oauth_token=gitlab_oauth_token, job_token=gitlab_job_token, api_version=4) + # We can create an oauth_token using a username and password + # https://docs.gitlab.com/ee/api/oauth2.html#authorization-code-flow + if gitlab_user: + data = {'grant_type': 'password', 'username': gitlab_user, 'password': gitlab_password} + resp = requests.post(urljoin(gitlab_url, "oauth/token"), data=data, verify=verify) + resp_data = resp.json() + gitlab_oauth_token = resp_data["access_token"] + gitlab_instance = gitlab.Gitlab(url=gitlab_url, ssl_verify=verify, private_token=gitlab_token, + oauth_token=gitlab_oauth_token, job_token=gitlab_job_token, api_version=4) gitlab_instance.auth() except (gitlab.exceptions.GitlabAuthenticationError, gitlab.exceptions.GitlabGetError) as e: module.fail_json(msg="Failed to connect to GitLab server: %s" % to_native(e)) diff --git a/plugins/module_utils/homebrew.py b/plugins/module_utils/homebrew.py new file mode 100644 index 0000000000..4b5c4672e4 --- /dev/null +++ b/plugins/module_utils/homebrew.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Ansible project +# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import os +import re +from ansible.module_utils.six import string_types + + +def _create_regex_group_complement(s): + lines = (line.strip() for line in s.split("\n") if line.strip()) + chars = filter(None, (line.split("#")[0].strip() for line in lines)) + group = r"[^" + r"".join(chars) + r"]" + return re.compile(group) + + +class HomebrewValidate(object): + # class regexes ------------------------------------------------ {{{ + VALID_PATH_CHARS = r""" + \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) + \s # spaces + : # colons + {sep} # the OS-specific path separator + . # dots + \- # dashes + """.format( + sep=os.path.sep + ) + + VALID_BREW_PATH_CHARS = r""" + \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) + \s # spaces + {sep} # the OS-specific path separator + . # dots + \- # dashes + """.format( + sep=os.path.sep + ) + + VALID_PACKAGE_CHARS = r""" + \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) + . # dots + / # slash (for taps) + \+ # plusses + \- # dashes + : # colons (for URLs) + @ # at-sign + """ + + INVALID_PATH_REGEX = _create_regex_group_complement(VALID_PATH_CHARS) + INVALID_BREW_PATH_REGEX = _create_regex_group_complement(VALID_BREW_PATH_CHARS) + INVALID_PACKAGE_REGEX = _create_regex_group_complement(VALID_PACKAGE_CHARS) + # /class regexes ----------------------------------------------- }}} + + # class validations -------------------------------------------- {{{ + @classmethod + def valid_path(cls, path): + """ + `path` must be one of: + - list of paths + - a string containing only: + - alphanumeric characters + - dashes + - dots + - spaces + - colons + - os.path.sep + """ + + if isinstance(path, string_types): + return not cls.INVALID_PATH_REGEX.search(path) + + try: + iter(path) + except TypeError: + return False + else: + paths = path + return all(cls.valid_brew_path(path_) for path_ in paths) + + @classmethod + def valid_brew_path(cls, brew_path): + """ + `brew_path` must be one of: + - None + - a string containing only: + - alphanumeric characters + - dashes + - dots + - spaces + - os.path.sep + """ + + if brew_path is None: + return True + + return isinstance( + brew_path, string_types + ) and not cls.INVALID_BREW_PATH_REGEX.search(brew_path) + + @classmethod + def valid_package(cls, package): + """A valid package is either None or alphanumeric.""" + + if package is None: + return True + + return isinstance( + package, string_types + ) and not cls.INVALID_PACKAGE_REGEX.search(package) + + +def parse_brew_path(module): + # type: (...) -> str + """Attempt to find the Homebrew executable path. + + Requires: + - module has a `path` parameter + - path is a valid path string for the target OS. Otherwise, module.fail_json() + is called with msg="Invalid_path: ". + """ + path = module.params["path"] + if not HomebrewValidate.valid_path(path): + module.fail_json(msg="Invalid path: {0}".format(path)) + + if isinstance(path, string_types): + paths = path.split(":") + elif isinstance(path, list): + paths = path + else: + module.fail_json(msg="Invalid path: {0}".format(path)) + + brew_path = module.get_bin_path("brew", required=True, opt_dirs=paths) + if not HomebrewValidate.valid_brew_path(brew_path): + module.fail_json(msg="Invalid brew path: {0}".format(brew_path)) + + return brew_path diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 9e1c3f4d93..128b0fee13 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -19,6 +19,7 @@ from ansible.module_utils.common.text.converters import to_native, to_text URL_REALM_INFO = "{url}/realms/{realm}" URL_REALMS = "{url}/admin/realms" URL_REALM = "{url}/admin/realms/{realm}" +URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys" URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" @@ -28,6 +29,9 @@ URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" +URL_CLIENT_ROLE_SCOPE_CLIENTS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{scopeid}" +URL_CLIENT_ROLE_SCOPE_REALM = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/realm" + URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" URL_REALM_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" @@ -181,8 +185,7 @@ def get_token(module_params): 'password': auth_password, } # Remove empty items, for instance missing client_secret - payload = dict( - (k, v) for k, v in temp_payload.items() if v is not None) + payload = {k: v for k, v in temp_payload.items() if v is not None} try: r = json.loads(to_native(open_url(auth_url, method='POST', validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, @@ -303,6 +306,37 @@ class KeycloakAPI(object): self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), exception=traceback.format_exc()) + def get_realm_keys_metadata_by_id(self, realm='master'): + """Obtain realm public info by id + + :param realm: realm id + + :return: None, or a 'KeysMetadataRepresentation' + (https://www.keycloak.org/docs-api/latest/rest-api/index.html#KeysMetadataRepresentation) + -- a dict containing the keys 'active' and 'keys', the former containing a mapping + from algorithms to key-ids, the latter containing a list of dicts with key + information. + """ + realm_keys_metadata_url = URL_REALM_KEYS_METADATA.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_keys_metadata_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.fail_open_url(e, msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + def get_realm_by_id(self, realm='master'): """ Obtain realm representation by id @@ -3049,6 +3083,105 @@ class KeycloakAPI(object): except Exception: return False + def get_client_role_scope_from_client(self, clientid, clientscopeid, realm="master"): + """ Fetch the roles associated with the client's scope for a specific client on the Keycloak server. + :param clientid: ID of the client from which to obtain the associated roles. + :param clientscopeid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the scope. + :return: The client scope of roles from specified client. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + try: + return json.loads(to_native(open_url(client_role_scope_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not fetch roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + def update_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm="master"): + """ Update and fetch the roles associated with the client's scope on the Keycloak server. + :param payload: List of roles to be added to the scope. + :param clientid: ID of the client to update scope. + :param clientscopeid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the clients. + :return: The client scope of roles from specified client. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + try: + open_url(client_role_scope_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not update roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) + + def delete_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm="master"): + """ Delete the roles contains in the payload from the client's scope on the Keycloak server. + :param payload: List of roles to be deleted. + :param clientid: ID of the client to delete roles from scope. + :param clientscopeid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the clients. + :return: The client scope of roles from specified client. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_CLIENTS.format(url=self.baseurl, realm=realm, id=clientid, scopeid=clientscopeid) + try: + open_url(client_role_scope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not delete roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) + + def get_client_role_scope_from_realm(self, clientid, realm="master"): + """ Fetch the realm roles from the client's scope on the Keycloak server. + :param clientid: ID of the client from which to obtain the associated realm roles. + :param realm: Realm from which to obtain the clients. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + return json.loads(to_native(open_url(client_role_scope_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.fail_open_url(e, msg='Could not fetch roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + def update_client_role_scope_from_realm(self, payload, clientid, realm="master"): + """ Update and fetch the realm roles from the client's scope on the Keycloak server. + :param payload: List of realm roles to add. + :param clientid: ID of the client to update scope. + :param realm: Realm from which to obtain the clients. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + open_url(client_role_scope_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not update roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_realm(clientid, realm) + + def delete_client_role_scope_from_realm(self, payload, clientid, realm="master"): + """ Delete the realm roles contains in the payload from the client's scope on the Keycloak server. + :param payload: List of realm roles to delete. + :param clientid: ID of the client to delete roles from scope. + :param realm: Realm from which to obtain the clients. + :return: The client realm roles scope. + """ + client_role_scope_url = URL_CLIENT_ROLE_SCOPE_REALM.format(url=self.baseurl, realm=realm, id=clientid) + try: + open_url(client_role_scope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(payload), validate_certs=self.validate_certs) + + except Exception as e: + self.fail_open_url(e, msg='Could not delete roles scope for client %s in realm %s: %s' % (clientid, realm, str(e))) + + return self.get_client_role_scope_from_realm(clientid, realm) + def fail_open_url(self, e, msg, **kwargs): try: if isinstance(e, HTTPError): diff --git a/plugins/module_utils/ilo_redfish_utils.py b/plugins/module_utils/ilo_redfish_utils.py index 9cb6e527a3..808583ae63 100644 --- a/plugins/module_utils/ilo_redfish_utils.py +++ b/plugins/module_utils/ilo_redfish_utils.py @@ -29,6 +29,7 @@ class iLORedfishUtils(RedfishUtils): result['ret'] = True data = response['data'] + current_session = None if 'Oem' in data: if data["Oem"]["Hpe"]["Links"]["MySession"]["@odata.id"]: current_session = data["Oem"]["Hpe"]["Links"]["MySession"]["@odata.id"] diff --git a/plugins/module_utils/ipa.py b/plugins/module_utils/ipa.py index eda9b4132b..fb63d5556b 100644 --- a/plugins/module_utils/ipa.py +++ b/plugins/module_utils/ipa.py @@ -104,7 +104,7 @@ class IPAClient(object): def get_ipa_version(self): response = self.ping()['summary'] - ipa_ver_regex = re.compile(r'IPA server version (\d\.\d\.\d).*') + ipa_ver_regex = re.compile(r'IPA server version (\d+\.\d+\.\d+).*') version_match = ipa_ver_regex.match(response) ipa_version = None if version_match: diff --git a/plugins/module_utils/mh/deco.py b/plugins/module_utils/mh/deco.py index 5138b212c7..408891cb8e 100644 --- a/plugins/module_utils/mh/deco.py +++ b/plugins/module_utils/mh/deco.py @@ -13,23 +13,27 @@ from functools import wraps from ansible_collections.community.general.plugins.module_utils.mh.exceptions import ModuleHelperException -def cause_changes(on_success=None, on_failure=None): +def cause_changes(on_success=None, on_failure=None, when=None): + # Parameters on_success and on_failure are deprecated and should be removed in community.general 12.0.0 def deco(func): - if on_success is None and on_failure is None: - return func - @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(self, *args, **kwargs): try: - self = args[0] - func(*args, **kwargs) + func(self, *args, **kwargs) if on_success is not None: self.changed = on_success + elif when == "success": + self.changed = True except Exception: if on_failure is not None: self.changed = on_failure + elif when == "failure": + self.changed = True raise + finally: + if when == "always": + self.changed = True return wrapper @@ -41,17 +45,15 @@ def module_fails_on_exception(func): @wraps(func) def wrapper(self, *args, **kwargs): + def fix_key(k): + return k if k not in conflict_list else "_" + k + def fix_var_conflicts(output): - result = dict([ - (k if k not in conflict_list else "_" + k, v) - for k, v in output.items() - ]) + result = {fix_key(k): v for k, v in output.items()} return result try: func(self, *args, **kwargs) - except SystemExit: - raise except ModuleHelperException as e: if e.update_output: self.update_output(e.update_output) @@ -73,6 +75,7 @@ def check_mode_skip(func): def wrapper(self, *args, **kwargs): if not self.module.check_mode: return func(self, *args, **kwargs) + return wrapper @@ -87,7 +90,7 @@ def check_mode_skip_returns(callable=None, value=None): return func(self, *args, **kwargs) return wrapper_callable - if value is not None: + else: @wraps(func) def wrapper_value(self, *args, **kwargs): if self.module.check_mode: @@ -95,7 +98,4 @@ def check_mode_skip_returns(callable=None, value=None): return func(self, *args, **kwargs) return wrapper_value - if callable is None and value is None: - return check_mode_skip - return deco diff --git a/plugins/module_utils/mh/mixins/deps.py b/plugins/module_utils/mh/mixins/deps.py index 772df8c0e9..dd879ff4b2 100644 --- a/plugins/module_utils/mh/mixins/deps.py +++ b/plugins/module_utils/mh/mixins/deps.py @@ -7,13 +7,14 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -import traceback - -from ansible_collections.community.general.plugins.module_utils.mh.base import ModuleHelperBase -from ansible_collections.community.general.plugins.module_utils.mh.deco import module_fails_on_exception - class DependencyCtxMgr(object): + """ + DEPRECATION WARNING + + This class is deprecated and will be removed in community.general 11.0.0 + Modules should use plugins/module_utils/deps.py instead. + """ def __init__(self, name, msg=None): self.name = name self.msg = msg @@ -35,39 +36,3 @@ class DependencyCtxMgr(object): @property def text(self): return self.msg or str(self.exc_val) - - -class DependencyMixin(ModuleHelperBase): - """ - THIS CLASS IS BEING DEPRECATED. - See the deprecation notice in ``DependencyMixin.fail_on_missing_deps()`` below. - - Mixin for mapping module options to running a CLI command with its arguments. - """ - _dependencies = [] - - @classmethod - def dependency(cls, name, msg): - cls._dependencies.append(DependencyCtxMgr(name, msg)) - return cls._dependencies[-1] - - def fail_on_missing_deps(self): - if not self._dependencies: - return - self.module.deprecate( - 'The DependencyMixin is being deprecated. ' - 'Modules should use community.general.plugins.module_utils.deps instead.', - version='9.0.0', - collection_name='community.general', - ) - for d in self._dependencies: - if not d.has_it: - self.module.fail_json(changed=False, - exception="\n".join(traceback.format_exception(d.exc_type, d.exc_val, d.exc_tb)), - msg=d.text, - **self.output) - - @module_fails_on_exception - def run(self): - self.fail_on_missing_deps() - super(DependencyMixin, self).run() diff --git a/plugins/module_utils/mh/mixins/vars.py b/plugins/module_utils/mh/mixins/vars.py index 91f4e4a189..7db9904f93 100644 --- a/plugins/module_utils/mh/mixins/vars.py +++ b/plugins/module_utils/mh/mixins/vars.py @@ -14,7 +14,7 @@ class VarMeta(object): """ DEPRECATION WARNING - This class is deprecated and will be removed in community.general 10.0.0 + This class is deprecated and will be removed in community.general 11.0.0 Modules should use the VarDict from plugins/module_utils/vardict.py instead. """ @@ -70,7 +70,7 @@ class VarDict(object): """ DEPRECATION WARNING - This class is deprecated and will be removed in community.general 10.0.0 + This class is deprecated and will be removed in community.general 11.0.0 Modules should use the VarDict from plugins/module_utils/vardict.py instead. """ def __init__(self): @@ -113,7 +113,7 @@ class VarDict(object): self._meta[name] = meta def output(self): - return dict((k, v) for k, v in self._data.items() if self.meta(k).output) + return {k: v for k, v in self._data.items() if self.meta(k).output} def diff(self): diff_results = [(k, self.meta(k).diff_result) for k in self._data] @@ -125,7 +125,7 @@ class VarDict(object): return None def facts(self): - facts_result = dict((k, v) for k, v in self._data.items() if self._meta[k].fact) + facts_result = {k: v for k, v in self._data.items() if self._meta[k].fact} return facts_result if facts_result else None def change_vars(self): @@ -139,7 +139,7 @@ class VarsMixin(object): """ DEPRECATION WARNING - This class is deprecated and will be removed in community.general 10.0.0 + This class is deprecated and will be removed in community.general 11.0.0 Modules should use the VarDict from plugins/module_utils/vardict.py instead. """ def __init__(self, module=None): diff --git a/plugins/module_utils/mh/module_helper.py b/plugins/module_utils/mh/module_helper.py index c33efb16b9..ca95199d9b 100644 --- a/plugins/module_utils/mh/module_helper.py +++ b/plugins/module_utils/mh/module_helper.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# (c) 2020, Alexei Znamensky -# Copyright (c) 2020, Ansible Project +# (c) 2020-2024, Alexei Znamensky +# Copyright (c) 2020-2024, Ansible Project # Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) # SPDX-License-Identifier: BSD-2-Clause @@ -10,23 +10,40 @@ __metaclass__ = type from ansible.module_utils.common.dict_transformations import dict_merge +from ansible_collections.community.general.plugins.module_utils.vardict import VarDict as _NewVarDict # remove "as NewVarDict" in 11.0.0 # (TODO: remove AnsibleModule!) pylint: disable-next=unused-import -from ansible_collections.community.general.plugins.module_utils.mh.base import ModuleHelperBase, AnsibleModule # noqa: F401 +from ansible_collections.community.general.plugins.module_utils.mh.base import AnsibleModule # noqa: F401 DEPRECATED, remove in 11.0.0 +from ansible_collections.community.general.plugins.module_utils.mh.base import ModuleHelperBase from ansible_collections.community.general.plugins.module_utils.mh.mixins.state import StateMixin -from ansible_collections.community.general.plugins.module_utils.mh.mixins.deps import DependencyMixin -from ansible_collections.community.general.plugins.module_utils.mh.mixins.vars import VarsMixin +# (TODO: remove mh.mixins.vars!) pylint: disable-next=unused-import +from ansible_collections.community.general.plugins.module_utils.mh.mixins.vars import VarsMixin, VarDict as _OldVarDict # noqa: F401 remove in 11.0.0 from ansible_collections.community.general.plugins.module_utils.mh.mixins.deprecate_attrs import DeprecateAttrsMixin -class ModuleHelper(DeprecateAttrsMixin, VarsMixin, DependencyMixin, ModuleHelperBase): +class ModuleHelper(DeprecateAttrsMixin, ModuleHelperBase): facts_name = None output_params = () diff_params = () change_params = () facts_params = () + use_old_vardict = True # remove in 11.0.0 + mute_vardict_deprecation = False def __init__(self, module=None): - super(ModuleHelper, self).__init__(module) + if self.use_old_vardict: # remove first half of the if in 11.0.0 + self.vars = _OldVarDict() + super(ModuleHelper, self).__init__(module) + if not self.mute_vardict_deprecation: + self.module.deprecate( + "This class is using the old VarDict from ModuleHelper, which is deprecated. " + "Set the class variable use_old_vardict to False and make the necessary adjustments." + "The old VarDict class will be removed in community.general 11.0.0", + version="11.0.0", collection_name="community.general" + ) + else: + self.vars = _NewVarDict() + super(ModuleHelper, self).__init__(module) + for name, value in self.module.params.items(): self.vars.set( name, value, @@ -36,6 +53,12 @@ class ModuleHelper(DeprecateAttrsMixin, VarsMixin, DependencyMixin, ModuleHelper fact=name in self.facts_params, ) + def update_vars(self, meta=None, **kwargs): + if meta is None: + meta = {} + for k, v in kwargs.items(): + self.vars.set(k, v, **meta) + def update_output(self, **kwargs): self.update_vars(meta={"output": True}, **kwargs) @@ -43,7 +66,10 @@ class ModuleHelper(DeprecateAttrsMixin, VarsMixin, DependencyMixin, ModuleHelper self.update_vars(meta={"fact": True}, **kwargs) def _vars_changed(self): - return any(self.vars.has_changed(v) for v in self.vars.change_vars()) + if self.use_old_vardict: + return any(self.vars.has_changed(v) for v in self.vars.change_vars()) + + return self.vars.has_changed def has_changed(self): return self.changed or self._vars_changed() diff --git a/plugins/module_utils/module_helper.py b/plugins/module_utils/module_helper.py index 5aa16c057a..366699329a 100644 --- a/plugins/module_utils/module_helper.py +++ b/plugins/module_utils/module_helper.py @@ -9,14 +9,14 @@ __metaclass__ = type # pylint: disable=unused-import - from ansible_collections.community.general.plugins.module_utils.mh.module_helper import ( - ModuleHelper, StateModuleHelper, AnsibleModule + ModuleHelper, StateModuleHelper, + AnsibleModule # remove in 11.0.0 ) -from ansible_collections.community.general.plugins.module_utils.mh.mixins.state import StateMixin # noqa: F401 -from ansible_collections.community.general.plugins.module_utils.mh.mixins.deps import DependencyCtxMgr, DependencyMixin # noqa: F401 +from ansible_collections.community.general.plugins.module_utils.mh.mixins.state import StateMixin # noqa: F401 remove in 11.0.0 +from ansible_collections.community.general.plugins.module_utils.mh.mixins.deps import DependencyCtxMgr # noqa: F401 remove in 11.0.0 from ansible_collections.community.general.plugins.module_utils.mh.exceptions import ModuleHelperException # noqa: F401 from ansible_collections.community.general.plugins.module_utils.mh.deco import ( cause_changes, module_fails_on_exception, check_mode_skip, check_mode_skip_returns, ) -from ansible_collections.community.general.plugins.module_utils.mh.mixins.vars import VarMeta, VarDict, VarsMixin # noqa: F401 +from ansible_collections.community.general.plugins.module_utils.mh.mixins.vars import VarMeta, VarDict, VarsMixin # noqa: F401 remove in 11.0.0 diff --git a/plugins/module_utils/ocapi_utils.py b/plugins/module_utils/ocapi_utils.py index 232c915060..8b8687199a 100644 --- a/plugins/module_utils/ocapi_utils.py +++ b/plugins/module_utils/ocapi_utils.py @@ -56,7 +56,7 @@ class OcapiUtils(object): follow_redirects='all', use_proxy=True, timeout=self.timeout) data = json.loads(to_native(resp.read())) - headers = dict((k.lower(), v) for (k, v) in resp.info().items()) + headers = {k.lower(): v for (k, v) in resp.info().items()} except HTTPError as e: return {'ret': False, 'msg': "HTTP Error %s on GET request to '%s'" @@ -86,7 +86,7 @@ class OcapiUtils(object): data = json.loads(to_native(resp.read())) else: data = "" - headers = dict((k.lower(), v) for (k, v) in resp.info().items()) + headers = {k.lower(): v for (k, v) in resp.info().items()} except HTTPError as e: return {'ret': False, 'msg': "HTTP Error %s on DELETE request to '%s'" @@ -113,7 +113,7 @@ class OcapiUtils(object): force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', use_proxy=True, timeout=self.timeout) - headers = dict((k.lower(), v) for (k, v) in resp.info().items()) + headers = {k.lower(): v for (k, v) in resp.info().items()} except HTTPError as e: return {'ret': False, 'msg': "HTTP Error %s on PUT request to '%s'" @@ -144,7 +144,7 @@ class OcapiUtils(object): force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', use_proxy=True, timeout=self.timeout if timeout is None else timeout) - headers = dict((k.lower(), v) for (k, v) in resp.info().items()) + headers = {k.lower(): v for (k, v) in resp.info().items()} except HTTPError as e: return {'ret': False, 'msg': "HTTP Error %s on POST request to '%s'" diff --git a/plugins/module_utils/pipx.py b/plugins/module_utils/pipx.py index a385ec93e7..9ae7b5381c 100644 --- a/plugins/module_utils/pipx.py +++ b/plugins/module_utils/pipx.py @@ -11,39 +11,47 @@ from ansible_collections.community.general.plugins.module_utils.cmd_runner impor _state_map = dict( install='install', + install_all='install-all', present='install', uninstall='uninstall', absent='uninstall', uninstall_all='uninstall-all', inject='inject', + uninject='uninject', upgrade='upgrade', + upgrade_shared='upgrade-shared', upgrade_all='upgrade-all', reinstall='reinstall', reinstall_all='reinstall-all', + pin='pin', + unpin='unpin', ) def pipx_runner(module, command, **kwargs): + arg_formats = dict( + state=fmt.as_map(_state_map), + name=fmt.as_list(), + name_source=fmt.as_func(fmt.unpack_args(lambda n, s: [s] if s else [n])), + install_apps=fmt.as_bool("--include-apps"), + install_deps=fmt.as_bool("--include-deps"), + inject_packages=fmt.as_list(), + force=fmt.as_bool("--force"), + include_injected=fmt.as_bool("--include-injected"), + index_url=fmt.as_opt_val('--index-url'), + python=fmt.as_opt_val('--python'), + system_site_packages=fmt.as_bool("--system-site-packages"), + _list=fmt.as_fixed(['list', '--include-injected', '--json']), + editable=fmt.as_bool("--editable"), + pip_args=fmt.as_opt_eq_val('--pip-args'), + suffix=fmt.as_opt_val('--suffix'), + ) + arg_formats["global"] = fmt.as_bool("--global") + runner = CmdRunner( module, command=command, - arg_formats=dict( - - state=fmt.as_map(_state_map), - name=fmt.as_list(), - name_source=fmt.as_func(fmt.unpack_args(lambda n, s: [s] if s else [n])), - install_apps=fmt.as_bool("--include-apps"), - install_deps=fmt.as_bool("--include-deps"), - inject_packages=fmt.as_list(), - force=fmt.as_bool("--force"), - include_injected=fmt.as_bool("--include-injected"), - index_url=fmt.as_opt_val('--index-url'), - python=fmt.as_opt_val('--python'), - system_site_packages=fmt.as_bool("--system-site-packages"), - _list=fmt.as_fixed(['list', '--include-injected', '--json']), - editable=fmt.as_bool("--editable"), - pip_args=fmt.as_opt_eq_val('--pip-args'), - ), + arg_formats=arg_formats, environ_update={'USE_EMOJI': '0'}, check_rc=True, **kwargs diff --git a/plugins/module_utils/proxmox.py b/plugins/module_utils/proxmox.py index 5fd783d654..05bf1874b3 100644 --- a/plugins/module_utils/proxmox.py +++ b/plugins/module_utils/proxmox.py @@ -29,6 +29,9 @@ def proxmox_auth_argument_spec(): required=True, fallback=(env_fallback, ['PROXMOX_HOST']) ), + api_port=dict(type='int', + fallback=(env_fallback, ['PROXMOX_PORT']) + ), api_user=dict(type='str', required=True, fallback=(env_fallback, ['PROXMOX_USER']) @@ -82,6 +85,7 @@ class ProxmoxAnsible(object): def _connect(self): api_host = self.module.params['api_host'] + api_port = self.module.params['api_port'] api_user = self.module.params['api_user'] api_password = self.module.params['api_password'] api_token_id = self.module.params['api_token_id'] @@ -89,6 +93,10 @@ class ProxmoxAnsible(object): validate_certs = self.module.params['validate_certs'] auth_args = {'user': api_user} + + if api_port: + auth_args['port'] = api_port + if api_password: auth_args['password'] = api_password else: diff --git a/plugins/module_utils/puppet.py b/plugins/module_utils/puppet.py index 8d553a2d28..e06683b3ee 100644 --- a/plugins/module_utils/puppet.py +++ b/plugins/module_utils/puppet.py @@ -103,9 +103,11 @@ def puppet_runner(module): modulepath=cmd_runner_fmt.as_opt_eq_val("--modulepath"), _execute=cmd_runner_fmt.as_func(execute_func), summarize=cmd_runner_fmt.as_bool("--summarize"), + waitforlock=cmd_runner_fmt.as_opt_val("--waitforlock"), debug=cmd_runner_fmt.as_bool("--debug"), verbose=cmd_runner_fmt.as_bool("--verbose"), ), check_rc=False, + force_lang=module.params["environment_lang"], ) return runner diff --git a/plugins/module_utils/python_runner.py b/plugins/module_utils/python_runner.py new file mode 100644 index 0000000000..f678f247b4 --- /dev/null +++ b/plugins/module_utils/python_runner.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import os + +from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, _ensure_list + + +class PythonRunner(CmdRunner): + def __init__(self, module, command, arg_formats=None, default_args_order=(), + check_rc=False, force_lang="C", path_prefix=None, environ_update=None, + python="python", venv=None): + self.python = python + self.venv = venv + self.has_venv = venv is not None + + if (os.path.isabs(python) or '/' in python): + self.python = python + elif self.has_venv: + path_prefix = os.path.join(venv, "bin") + if environ_update is None: + environ_update = {} + environ_update["PATH"] = "%s:%s" % (path_prefix, os.environ["PATH"]) + environ_update["VIRTUAL_ENV"] = venv + + python_cmd = [self.python] + _ensure_list(command) + + super(PythonRunner, self).__init__(module, python_cmd, arg_formats, default_args_order, + check_rc, force_lang, path_prefix, environ_update) diff --git a/plugins/module_utils/rax.py b/plugins/module_utils/rax.py deleted file mode 100644 index 6331c0d1be..0000000000 --- a/plugins/module_utils/rax.py +++ /dev/null @@ -1,334 +0,0 @@ -# -*- coding: utf-8 -*- -# This code is part of Ansible, but is an independent component. -# This particular file snippet, and this file snippet only, is BSD licensed. -# Modules you write using this snippet, which is embedded dynamically by -# Ansible still belong to the author of the module, and may assign their own -# license to the complete work. -# -# Copyright (c), Michael DeHaan , 2012-2013 -# -# Simplified BSD License (see LICENSES/BSD-2-Clause.txt or https://opensource.org/licenses/BSD-2-Clause) -# SPDX-License-Identifier: BSD-2-Clause - -from __future__ import (absolute_import, division, print_function) -__metaclass__ = type - - -import os -import re -from uuid import UUID - -from ansible.module_utils.six import text_type, binary_type - -FINAL_STATUSES = ('ACTIVE', 'ERROR') -VOLUME_STATUS = ('available', 'attaching', 'creating', 'deleting', 'in-use', - 'error', 'error_deleting') - -CLB_ALGORITHMS = ['RANDOM', 'LEAST_CONNECTIONS', 'ROUND_ROBIN', - 'WEIGHTED_LEAST_CONNECTIONS', 'WEIGHTED_ROUND_ROBIN'] -CLB_PROTOCOLS = ['DNS_TCP', 'DNS_UDP', 'FTP', 'HTTP', 'HTTPS', 'IMAPS', - 'IMAPv4', 'LDAP', 'LDAPS', 'MYSQL', 'POP3', 'POP3S', 'SMTP', - 'TCP', 'TCP_CLIENT_FIRST', 'UDP', 'UDP_STREAM', 'SFTP'] - -NON_CALLABLES = (text_type, binary_type, bool, dict, int, list, type(None)) -PUBLIC_NET_ID = "00000000-0000-0000-0000-000000000000" -SERVICE_NET_ID = "11111111-1111-1111-1111-111111111111" - - -def rax_slugify(value): - """Prepend a key with rax_ and normalize the key name""" - return 'rax_%s' % (re.sub(r'[^\w-]', '_', value).lower().lstrip('_')) - - -def rax_clb_node_to_dict(obj): - """Function to convert a CLB Node object to a dict""" - if not obj: - return {} - node = obj.to_dict() - node['id'] = obj.id - node['weight'] = obj.weight - return node - - -def rax_to_dict(obj, obj_type='standard'): - """Generic function to convert a pyrax object to a dict - - obj_type values: - standard - clb - server - - """ - instance = {} - for key in dir(obj): - value = getattr(obj, key) - if obj_type == 'clb' and key == 'nodes': - instance[key] = [] - for node in value: - instance[key].append(rax_clb_node_to_dict(node)) - elif (isinstance(value, list) and len(value) > 0 and - not isinstance(value[0], NON_CALLABLES)): - instance[key] = [] - for item in value: - instance[key].append(rax_to_dict(item)) - elif (isinstance(value, NON_CALLABLES) and not key.startswith('_')): - if obj_type == 'server': - if key == 'image': - if not value: - instance['rax_boot_source'] = 'volume' - else: - instance['rax_boot_source'] = 'local' - key = rax_slugify(key) - instance[key] = value - - if obj_type == 'server': - for attr in ['id', 'accessIPv4', 'name', 'status']: - instance[attr] = instance.get(rax_slugify(attr)) - - return instance - - -def rax_find_bootable_volume(module, rax_module, server, exit=True): - """Find a servers bootable volume""" - cs = rax_module.cloudservers - cbs = rax_module.cloud_blockstorage - server_id = rax_module.utils.get_id(server) - volumes = cs.volumes.get_server_volumes(server_id) - bootable_volumes = [] - for volume in volumes: - vol = cbs.get(volume) - if module.boolean(vol.bootable): - bootable_volumes.append(vol) - if not bootable_volumes: - if exit: - module.fail_json(msg='No bootable volumes could be found for ' - 'server %s' % server_id) - else: - return False - elif len(bootable_volumes) > 1: - if exit: - module.fail_json(msg='Multiple bootable volumes found for server ' - '%s' % server_id) - else: - return False - - return bootable_volumes[0] - - -def rax_find_image(module, rax_module, image, exit=True): - """Find a server image by ID or Name""" - cs = rax_module.cloudservers - try: - UUID(image) - except ValueError: - try: - image = cs.images.find(human_id=image) - except (cs.exceptions.NotFound, cs.exceptions.NoUniqueMatch): - try: - image = cs.images.find(name=image) - except (cs.exceptions.NotFound, - cs.exceptions.NoUniqueMatch): - if exit: - module.fail_json(msg='No matching image found (%s)' % - image) - else: - return False - - return rax_module.utils.get_id(image) - - -def rax_find_volume(module, rax_module, name): - """Find a Block storage volume by ID or name""" - cbs = rax_module.cloud_blockstorage - try: - UUID(name) - volume = cbs.get(name) - except ValueError: - try: - volume = cbs.find(name=name) - except rax_module.exc.NotFound: - volume = None - except Exception as e: - module.fail_json(msg='%s' % e) - return volume - - -def rax_find_network(module, rax_module, network): - """Find a cloud network by ID or name""" - cnw = rax_module.cloud_networks - try: - UUID(network) - except ValueError: - if network.lower() == 'public': - return cnw.get_server_networks(PUBLIC_NET_ID) - elif network.lower() == 'private': - return cnw.get_server_networks(SERVICE_NET_ID) - else: - try: - network_obj = cnw.find_network_by_label(network) - except (rax_module.exceptions.NetworkNotFound, - rax_module.exceptions.NetworkLabelNotUnique): - module.fail_json(msg='No matching network found (%s)' % - network) - else: - return cnw.get_server_networks(network_obj) - else: - return cnw.get_server_networks(network) - - -def rax_find_server(module, rax_module, server): - """Find a Cloud Server by ID or name""" - cs = rax_module.cloudservers - try: - UUID(server) - server = cs.servers.get(server) - except ValueError: - servers = cs.servers.list(search_opts=dict(name='^%s$' % server)) - if not servers: - module.fail_json(msg='No Server was matched by name, ' - 'try using the Server ID instead') - if len(servers) > 1: - module.fail_json(msg='Multiple servers matched by name, ' - 'try using the Server ID instead') - - # We made it this far, grab the first and hopefully only server - # in the list - server = servers[0] - return server - - -def rax_find_loadbalancer(module, rax_module, loadbalancer): - """Find a Cloud Load Balancer by ID or name""" - clb = rax_module.cloud_loadbalancers - try: - found = clb.get(loadbalancer) - except Exception: - found = [] - for lb in clb.list(): - if loadbalancer == lb.name: - found.append(lb) - - if not found: - module.fail_json(msg='No loadbalancer was matched') - - if len(found) > 1: - module.fail_json(msg='Multiple loadbalancers matched') - - # We made it this far, grab the first and hopefully only item - # in the list - found = found[0] - - return found - - -def rax_argument_spec(): - """Return standard base dictionary used for the argument_spec - argument in AnsibleModule - - """ - return dict( - api_key=dict(type='str', aliases=['password'], no_log=True), - auth_endpoint=dict(type='str'), - credentials=dict(type='path', aliases=['creds_file']), - env=dict(type='str'), - identity_type=dict(type='str', default='rackspace'), - region=dict(type='str'), - tenant_id=dict(type='str'), - tenant_name=dict(type='str'), - username=dict(type='str'), - validate_certs=dict(type='bool', aliases=['verify_ssl']), - ) - - -def rax_required_together(): - """Return the default list used for the required_together argument to - AnsibleModule""" - return [['api_key', 'username']] - - -def setup_rax_module(module, rax_module, region_required=True): - """Set up pyrax in a standard way for all modules""" - rax_module.USER_AGENT = 'ansible/%s %s' % (module.ansible_version, - rax_module.USER_AGENT) - - api_key = module.params.get('api_key') - auth_endpoint = module.params.get('auth_endpoint') - credentials = module.params.get('credentials') - env = module.params.get('env') - identity_type = module.params.get('identity_type') - region = module.params.get('region') - tenant_id = module.params.get('tenant_id') - tenant_name = module.params.get('tenant_name') - username = module.params.get('username') - verify_ssl = module.params.get('validate_certs') - - if env is not None: - rax_module.set_environment(env) - - rax_module.set_setting('identity_type', identity_type) - if verify_ssl is not None: - rax_module.set_setting('verify_ssl', verify_ssl) - if auth_endpoint is not None: - rax_module.set_setting('auth_endpoint', auth_endpoint) - if tenant_id is not None: - rax_module.set_setting('tenant_id', tenant_id) - if tenant_name is not None: - rax_module.set_setting('tenant_name', tenant_name) - - try: - username = username or os.environ.get('RAX_USERNAME') - if not username: - username = rax_module.get_setting('keyring_username') - if username: - api_key = 'USE_KEYRING' - if not api_key: - api_key = os.environ.get('RAX_API_KEY') - credentials = (credentials or os.environ.get('RAX_CREDENTIALS') or - os.environ.get('RAX_CREDS_FILE')) - region = (region or os.environ.get('RAX_REGION') or - rax_module.get_setting('region')) - except KeyError as e: - module.fail_json(msg='Unable to load %s' % e.message) - - try: - if api_key and username: - if api_key == 'USE_KEYRING': - rax_module.keyring_auth(username, region=region) - else: - rax_module.set_credentials(username, api_key=api_key, - region=region) - elif credentials: - credentials = os.path.expanduser(credentials) - rax_module.set_credential_file(credentials, region=region) - else: - raise Exception('No credentials supplied!') - except Exception as e: - if e.message: - msg = str(e.message) - else: - msg = repr(e) - module.fail_json(msg=msg) - - if region_required and region not in rax_module.regions: - module.fail_json(msg='%s is not a valid region, must be one of: %s' % - (region, ','.join(rax_module.regions))) - - return rax_module - - -def rax_scaling_group_personality_file(module, files): - if not files: - return [] - - results = [] - for rpath, lpath in files.items(): - lpath = os.path.expanduser(lpath) - try: - with open(lpath, 'r') as f: - results.append({ - 'path': rpath, - 'contents': f.read(), - }) - except Exception as e: - module.fail_json(msg='Failed to load %s: %s' % (lpath, str(e))) - return results diff --git a/plugins/module_utils/redfish_utils.py b/plugins/module_utils/redfish_utils.py index 6f56f559fd..a63db7fbbc 100644 --- a/plugins/module_utils/redfish_utils.py +++ b/plugins/module_utils/redfish_utils.py @@ -11,6 +11,7 @@ import os import random import string import gzip +import time from io import BytesIO from ansible.module_utils.urls import open_url from ansible.module_utils.common.text.converters import to_native @@ -41,7 +42,7 @@ FAIL_MSG = 'Issuing a data modification command without specifying the '\ class RedfishUtils(object): def __init__(self, creds, root_uri, timeout, module, resource_id=None, - data_modification=False, strip_etag_quotes=False): + data_modification=False, strip_etag_quotes=False, ciphers=None): self.root_uri = root_uri self.creds = creds self.timeout = timeout @@ -52,6 +53,7 @@ class RedfishUtils(object): self.resource_id = resource_id self.data_modification = data_modification self.strip_etag_quotes = strip_etag_quotes + self.ciphers = ciphers self._vendor = None self._init_session() @@ -132,11 +134,13 @@ class RedfishUtils(object): return resp # The following functions are to send GET/POST/PATCH/DELETE requests - def get_request(self, uri, override_headers=None, allow_no_resp=False): + def get_request(self, uri, override_headers=None, allow_no_resp=False, timeout=None): req_headers = dict(GET_HEADERS) if override_headers: req_headers.update(override_headers) username, password, basic_auth = self._auth_params(req_headers) + if timeout is None: + timeout = self.timeout try: # Service root is an unauthenticated resource; remove credentials # in case the caller will be using sessions later. @@ -146,8 +150,8 @@ class RedfishUtils(object): url_username=username, url_password=password, force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', - use_proxy=True, timeout=self.timeout) - headers = dict((k.lower(), v) for (k, v) in resp.info().items()) + use_proxy=True, timeout=timeout, ciphers=self.ciphers) + headers = {k.lower(): v for (k, v) in resp.info().items()} try: if headers.get('content-encoding') == 'gzip' and LooseVersion(ansible_version) < LooseVersion('2.14'): # Older versions of Ansible do not automatically decompress the data @@ -196,13 +200,13 @@ class RedfishUtils(object): url_username=username, url_password=password, force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', - use_proxy=True, timeout=self.timeout) + use_proxy=True, timeout=self.timeout, ciphers=self.ciphers) try: data = json.loads(to_native(resp.read())) except Exception as e: # No response data; this is okay in many cases data = None - headers = dict((k.lower(), v) for (k, v) in resp.info().items()) + headers = {k.lower(): v for (k, v) in resp.info().items()} except HTTPError as e: msg = self._get_extended_message(e) return {'ret': False, @@ -250,7 +254,7 @@ class RedfishUtils(object): url_username=username, url_password=password, force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', - use_proxy=True, timeout=self.timeout) + use_proxy=True, timeout=self.timeout, ciphers=self.ciphers) except HTTPError as e: msg = self._get_extended_message(e) return {'ret': False, 'changed': False, @@ -285,7 +289,7 @@ class RedfishUtils(object): url_username=username, url_password=password, force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', - use_proxy=True, timeout=self.timeout) + use_proxy=True, timeout=self.timeout, ciphers=self.ciphers) except HTTPError as e: msg = self._get_extended_message(e) return {'ret': False, @@ -311,7 +315,7 @@ class RedfishUtils(object): url_username=username, url_password=password, force_basic_auth=basic_auth, validate_certs=False, follow_redirects='all', - use_proxy=True, timeout=self.timeout) + use_proxy=True, timeout=self.timeout, ciphers=self.ciphers) except HTTPError as e: msg = self._get_extended_message(e) return {'ret': False, @@ -606,12 +610,13 @@ class RedfishUtils(object): data = response['data'] if 'Parameters' in data: params = data['Parameters'] - ai = dict((p['Name'], p) - for p in params if 'Name' in p) + ai = {p['Name']: p for p in params if 'Name' in p} if not ai: - ai = dict((k[:-24], - {'AllowableValues': v}) for k, v in action.items() - if k.endswith('@Redfish.AllowableValues')) + ai = { + k[:-24]: {'AllowableValues': v} + for k, v in action.items() + if k.endswith('@Redfish.AllowableValues') + } return ai def _get_allowable_values(self, action, name, default_values=None): @@ -630,7 +635,25 @@ class RedfishUtils(object): else: return self.get_logs(self.manager_uri) - def get_logs(self, manager_uri): + def check_service_availability(self): + """ + Checks if the service is accessible. + + :return: dict containing the status of the service + """ + + # Get the service root + # Override the timeout since the service root is expected to be readily + # available. + service_root = self.get_request(self.root_uri + self.service_root, timeout=10) + if service_root['ret'] is False: + # Failed, either due to a timeout or HTTP error; not available + return {'ret': True, 'available': False} + + # Successfully accessed the service root; available + return {'ret': True, 'available': True} + + def get_logs(self): log_svcs_uri_list = [] list_of_logs = [] properties = ['Severity', 'Created', 'EntryType', 'OemRecordFormat', @@ -1089,11 +1112,12 @@ class RedfishUtils(object): return self.manage_power(command, self.systems_uri, '#ComputerSystem.Reset') - def manage_manager_power(self, command): + def manage_manager_power(self, command, wait=False, wait_timeout=120): return self.manage_power(command, self.manager_uri, - '#Manager.Reset') + '#Manager.Reset', wait, wait_timeout) - def manage_power(self, command, resource_uri, action_name): + def manage_power(self, command, resource_uri, action_name, wait=False, + wait_timeout=120): key = "Actions" reset_type_values = ['On', 'ForceOff', 'GracefulShutdown', 'GracefulRestart', 'ForceRestart', 'Nmi', @@ -1149,6 +1173,78 @@ class RedfishUtils(object): # define payload payload = {'ResetType': reset_type} + # POST to Action URI + response = self.post_request(self.root_uri + action_uri, payload) + if response['ret'] is False: + return response + + # If requested to wait for the service to be available again, block + # until it's ready + if wait: + elapsed_time = 0 + start_time = time.time() + # Start with a large enough sleep. Some services will process new + # requests while in the middle of shutting down, thus breaking out + # early. + time.sleep(30) + + # Periodically check for the service's availability. + while elapsed_time <= wait_timeout: + status = self.check_service_availability() + if status['available']: + # It's available; we're done + break + time.sleep(5) + elapsed_time = time.time() - start_time + + if elapsed_time > wait_timeout: + # Exhausted the wait timer; error + return {'ret': False, 'changed': True, + 'msg': 'The service did not become available after %d seconds' % wait_timeout} + return {'ret': True, 'changed': True} + + def manager_reset_to_defaults(self, command): + return self.reset_to_defaults(command, self.manager_uri, + '#Manager.ResetToDefaults') + + def reset_to_defaults(self, command, resource_uri, action_name): + key = "Actions" + reset_type_values = ['ResetAll', + 'PreserveNetworkAndUsers', + 'PreserveNetwork'] + + if command not in reset_type_values: + return {'ret': False, 'msg': 'Invalid Command (%s)' % command} + + # read the resource and get the current power state + response = self.get_request(self.root_uri + resource_uri) + if response['ret'] is False: + return response + data = response['data'] + + # get the reset Action and target URI + if key not in data or action_name not in data[key]: + return {'ret': False, 'msg': 'Action %s not found' % action_name} + reset_action = data[key][action_name] + if 'target' not in reset_action: + return {'ret': False, + 'msg': 'target URI missing from Action %s' % action_name} + action_uri = reset_action['target'] + + # get AllowableValues + ai = self._get_all_action_info_values(reset_action) + allowable_values = ai.get('ResetType', {}).get('AllowableValues', []) + + # map ResetType to an allowable value if needed + if allowable_values and command not in allowable_values: + return {'ret': False, + 'msg': 'Specified reset type (%s) not supported ' + 'by service. Supported types: %s' % + (command, allowable_values)} + + # define payload + payload = {'ResetType': command} + # POST to Action URI response = self.post_request(self.root_uri + action_uri, payload) if response['ret'] is False: @@ -1555,6 +1651,8 @@ class RedfishUtils(object): data = response['data'] + result['multipart_supported'] = 'MultipartHttpPushUri' in data + if "Actions" in data: actions = data['Actions'] if len(actions) > 0: @@ -2151,7 +2249,7 @@ class RedfishUtils(object): continue # If already set to requested value, remove it from PATCH payload - if data[u'Attributes'][attr_name] == attributes[attr_name]: + if data[u'Attributes'][attr_name] == attr_value: del attrs_to_patch[attr_name] warning = "" @@ -2689,9 +2787,11 @@ class RedfishUtils(object): def virtual_media_insert_via_patch(self, options, param_map, uri, data, image_only=False): # get AllowableValues - ai = dict((k[:-24], - {'AllowableValues': v}) for k, v in data.items() - if k.endswith('@Redfish.AllowableValues')) + ai = { + k[:-24]: {'AllowableValues': v} + for k, v in data.items() + if k.endswith('@Redfish.AllowableValues') + } # construct payload payload = self._insert_virt_media_payload(options, param_map, data, ai) if 'Inserted' not in payload and not image_only: @@ -3748,7 +3848,7 @@ class RedfishUtils(object): vendor = self._get_vendor()['Vendor'] rsp_uri = "" for loc in resp_data['Location']: - if loc['Language'] == "en": + if loc['Language'].startswith("en"): rsp_uri = loc['Uri'] if vendor == 'HPE': # WORKAROUND diff --git a/plugins/module_utils/redhat.py b/plugins/module_utils/redhat.py index 110159ddfc..321386a0a5 100644 --- a/plugins/module_utils/redhat.py +++ b/plugins/module_utils/redhat.py @@ -15,10 +15,8 @@ __metaclass__ = type import os -import re import shutil import tempfile -import types from ansible.module_utils.six.moves import configparser @@ -76,241 +74,3 @@ class RegistrationBase(object): def subscribe(self, **kwargs): raise NotImplementedError("Must be implemented by a sub-class") - - -class Rhsm(RegistrationBase): - """ - DEPRECATION WARNING - - This class is deprecated and will be removed in community.general 9.0.0. - There is no replacement for it; please contact the community.general - maintainers in case you are using it. - """ - - def __init__(self, module, username=None, password=None): - RegistrationBase.__init__(self, module, username, password) - self.config = self._read_config() - self.module = module - self.module.deprecate( - 'The Rhsm class is deprecated with no replacement.', - version='9.0.0', - collection_name='community.general', - ) - - def _read_config(self, rhsm_conf='/etc/rhsm/rhsm.conf'): - ''' - Load RHSM configuration from /etc/rhsm/rhsm.conf. - Returns: - * ConfigParser object - ''' - - # Read RHSM defaults ... - cp = configparser.ConfigParser() - cp.read(rhsm_conf) - - # Add support for specifying a default value w/o having to standup some configuration - # Yeah, I know this should be subclassed ... but, oh well - def get_option_default(self, key, default=''): - sect, opt = key.split('.', 1) - if self.has_section(sect) and self.has_option(sect, opt): - return self.get(sect, opt) - else: - return default - - cp.get_option = types.MethodType(get_option_default, cp, configparser.ConfigParser) - - return cp - - def enable(self): - ''' - Enable the system to receive updates from subscription-manager. - This involves updating affected yum plugins and removing any - conflicting yum repositories. - ''' - RegistrationBase.enable(self) - self.update_plugin_conf('rhnplugin', False) - self.update_plugin_conf('subscription-manager', True) - - def configure(self, **kwargs): - ''' - Configure the system as directed for registration with RHN - Raises: - * Exception - if error occurs while running command - ''' - args = ['subscription-manager', 'config'] - - # Pass supplied **kwargs as parameters to subscription-manager. Ignore - # non-configuration parameters and replace '_' with '.'. For example, - # 'server_hostname' becomes '--system.hostname'. - for k, v in kwargs.items(): - if re.search(r'^(system|rhsm)_', k): - args.append('--%s=%s' % (k.replace('_', '.'), v)) - - self.module.run_command(args, check_rc=True) - - @property - def is_registered(self): - ''' - Determine whether the current system - Returns: - * Boolean - whether the current system is currently registered to - RHN. - ''' - args = ['subscription-manager', 'identity'] - rc, stdout, stderr = self.module.run_command(args, check_rc=False) - if rc == 0: - return True - else: - return False - - def register(self, username, password, autosubscribe, activationkey): - ''' - Register the current system to the provided RHN server - Raises: - * Exception - if error occurs while running command - ''' - args = ['subscription-manager', 'register'] - - # Generate command arguments - if activationkey: - args.append('--activationkey "%s"' % activationkey) - else: - if autosubscribe: - args.append('--autosubscribe') - if username: - args.extend(['--username', username]) - if password: - args.extend(['--password', password]) - - # Do the needful... - rc, stderr, stdout = self.module.run_command(args, check_rc=True) - - def unsubscribe(self): - ''' - Unsubscribe a system from all subscribed channels - Raises: - * Exception - if error occurs while running command - ''' - args = ['subscription-manager', 'unsubscribe', '--all'] - rc, stderr, stdout = self.module.run_command(args, check_rc=True) - - def unregister(self): - ''' - Unregister a currently registered system - Raises: - * Exception - if error occurs while running command - ''' - args = ['subscription-manager', 'unregister'] - rc, stderr, stdout = self.module.run_command(args, check_rc=True) - self.update_plugin_conf('rhnplugin', False) - self.update_plugin_conf('subscription-manager', False) - - def subscribe(self, regexp): - ''' - Subscribe current system to available pools matching the specified - regular expression - Raises: - * Exception - if error occurs while running command - ''' - - # Available pools ready for subscription - available_pools = RhsmPools(self.module) - - for pool in available_pools.filter(regexp): - pool.subscribe() - - -class RhsmPool(object): - """ - Convenience class for housing subscription information - - DEPRECATION WARNING - - This class is deprecated and will be removed in community.general 9.0.0. - There is no replacement for it; please contact the community.general - maintainers in case you are using it. - """ - - def __init__(self, module, **kwargs): - self.module = module - for k, v in kwargs.items(): - setattr(self, k, v) - self.module.deprecate( - 'The RhsmPool class is deprecated with no replacement.', - version='9.0.0', - collection_name='community.general', - ) - - def __str__(self): - return str(self.__getattribute__('_name')) - - def subscribe(self): - args = "subscription-manager subscribe --pool %s" % self.PoolId - rc, stdout, stderr = self.module.run_command(args, check_rc=True) - if rc == 0: - return True - else: - return False - - -class RhsmPools(object): - """ - This class is used for manipulating pools subscriptions with RHSM - - DEPRECATION WARNING - - This class is deprecated and will be removed in community.general 9.0.0. - There is no replacement for it; please contact the community.general - maintainers in case you are using it. - """ - - def __init__(self, module): - self.module = module - self.products = self._load_product_list() - self.module.deprecate( - 'The RhsmPools class is deprecated with no replacement.', - version='9.0.0', - collection_name='community.general', - ) - - def __iter__(self): - return self.products.__iter__() - - def _load_product_list(self): - """ - Loads list of all available pools for system in data structure - """ - args = "subscription-manager list --available" - rc, stdout, stderr = self.module.run_command(args, check_rc=True) - - products = [] - for line in stdout.split('\n'): - # Remove leading+trailing whitespace - line = line.strip() - # An empty line implies the end of an output group - if len(line) == 0: - continue - # If a colon ':' is found, parse - elif ':' in line: - (key, value) = line.split(':', 1) - key = key.strip().replace(" ", "") # To unify - value = value.strip() - if key in ['ProductName', 'SubscriptionName']: - # Remember the name for later processing - products.append(RhsmPool(self.module, _name=value, key=value)) - elif products: - # Associate value with most recently recorded product - products[-1].__setattr__(key, value) - # FIXME - log some warning? - # else: - # warnings.warn("Unhandled subscription key/value: %s/%s" % (key,value)) - return products - - def filter(self, regexp='^$'): - ''' - Return a list of RhsmPools whose name matches the provided regular expression - ''' - r = re.compile(regexp) - for product in self.products: - if r.search(product._name): - yield product diff --git a/plugins/module_utils/redis.py b/plugins/module_utils/redis.py index c4d87aca51..e823f966dc 100644 --- a/plugins/module_utils/redis.py +++ b/plugins/module_utils/redis.py @@ -57,7 +57,9 @@ def redis_auth_argument_spec(tls_default=True): validate_certs=dict(type='bool', default=True ), - ca_certs=dict(type='str') + ca_certs=dict(type='str'), + client_cert_file=dict(type='str'), + client_key_file=dict(type='str'), ) @@ -71,6 +73,8 @@ def redis_auth_params(module): ca_certs = module.params['ca_certs'] if tls and ca_certs is None: ca_certs = str(certifi.where()) + client_cert_file = module.params['client_cert_file'] + client_key_file = module.params['client_key_file'] if tuple(map(int, redis_version.split('.'))) < (3, 4, 0) and login_user is not None: module.fail_json( msg='The option `username` in only supported with redis >= 3.4.0.') @@ -78,6 +82,8 @@ def redis_auth_params(module): 'port': login_port, 'password': login_password, 'ssl_ca_certs': ca_certs, + 'ssl_certfile': client_cert_file, + 'ssl_keyfile': client_key_file, 'ssl_cert_reqs': validate_certs, 'ssl': tls} if login_user is not None: diff --git a/plugins/module_utils/rundeck.py b/plugins/module_utils/rundeck.py index 7df68a3603..cffca7b4ee 100644 --- a/plugins/module_utils/rundeck.py +++ b/plugins/module_utils/rundeck.py @@ -28,7 +28,7 @@ def api_argument_spec(): return api_argument_spec -def api_request(module, endpoint, data=None, method="GET"): +def api_request(module, endpoint, data=None, method="GET", content_type="application/json"): """Manages Rundeck API requests via HTTP(S) :arg module: The AnsibleModule (used to get url, api_version, api_token, etc). @@ -63,7 +63,7 @@ def api_request(module, endpoint, data=None, method="GET"): data=json.dumps(data), method=method, headers={ - "Content-Type": "application/json", + "Content-Type": content_type, "Accept": "application/json", "X-Rundeck-Auth-Token": module.params["api_token"] } diff --git a/plugins/module_utils/scaleway.py b/plugins/module_utils/scaleway.py index 67b821103a..4768aafc9c 100644 --- a/plugins/module_utils/scaleway.py +++ b/plugins/module_utils/scaleway.py @@ -17,6 +17,10 @@ from ansible.module_utils.basic import env_fallback, missing_required_lib from ansible.module_utils.urls import fetch_url from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + SCALEWAY_SECRET_IMP_ERR = None try: from passlib.hash import argon2 @@ -47,11 +51,11 @@ def scaleway_waitable_resource_argument_spec(): def payload_from_object(scw_object): - return dict( - (k, v) + return { + k: v for k, v in scw_object.items() if k != 'id' and v is not None - ) + } class ScalewayException(Exception): @@ -113,10 +117,7 @@ class SecretVariables(object): @staticmethod def list_to_dict(source_list, hashed=False): key_value = 'hashed_value' if hashed else 'value' - return dict( - (var['key'], var[key_value]) - for var in source_list - ) + return {var['key']: var[key_value] for var in source_list} @classmethod def decode(cls, secrets_list, values_list): @@ -139,7 +140,7 @@ def resource_attributes_should_be_changed(target, wished, verifiable_mutable_att diff[attr] = wished[attr] if diff: - return dict((attr, wished[attr]) for attr in mutable_attributes) + return {attr: wished[attr] for attr in mutable_attributes} else: return diff @@ -306,10 +307,10 @@ class Scaleway(object): # Prevent requesting the resource status too soon time.sleep(wait_sleep_time) - start = datetime.datetime.utcnow() + start = now() end = start + datetime.timedelta(seconds=wait_timeout) - while datetime.datetime.utcnow() < end: + while now() < end: self.module.debug("We are going to wait for the resource to finish its transition") state = self.fetch_state(resource) diff --git a/plugins/module_utils/vardict.py b/plugins/module_utils/vardict.py index cfcce4d4d2..9bd104ce37 100644 --- a/plugins/module_utils/vardict.py +++ b/plugins/module_utils/vardict.py @@ -100,7 +100,7 @@ class _Variable(object): return def __str__(self): - return "<_Variable: value={0!r}, initial={1!r}, diff={2}, output={3}, change={4}, verbosity={5}>".format( + return "".format( self.value, self.initial_value, self.diff, self.output, self.change, self.verbosity ) @@ -175,18 +175,18 @@ class VarDict(object): self.__vars__[name] = var def output(self, verbosity=0): - return dict((n, v.value) for n, v in self.__vars__.items() if v.output and v.is_visible(verbosity)) + return {n: v.value for n, v in self.__vars__.items() if v.output and v.is_visible(verbosity)} def diff(self, verbosity=0): diff_results = [(n, v.diff_result) for n, v in self.__vars__.items() if v.diff_result and v.is_visible(verbosity)] if diff_results: - before = dict((n, dr['before']) for n, dr in diff_results) - after = dict((n, dr['after']) for n, dr in diff_results) + before = {n: dr['before'] for n, dr in diff_results} + after = {n: dr['after'] for n, dr in diff_results} return {'before': before, 'after': after} return None def facts(self, verbosity=0): - facts_result = dict((n, v.value) for n, v in self.__vars__.items() if v.fact and v.is_visible(verbosity)) + facts_result = {n: v.value for n, v in self.__vars__.items() if v.fact and v.is_visible(verbosity)} return facts_result if facts_result else None @property @@ -194,4 +194,4 @@ class VarDict(object): return any(var.has_changed for var in self.__vars__.values()) def as_dict(self): - return dict((name, var.value) for name, var in self.__vars__.items()) + return {name: var.value for name, var in self.__vars__.items()} diff --git a/plugins/module_utils/wdc_redfish_utils.py b/plugins/module_utils/wdc_redfish_utils.py index bc4b0c2cd0..8c6fd71bf8 100644 --- a/plugins/module_utils/wdc_redfish_utils.py +++ b/plugins/module_utils/wdc_redfish_utils.py @@ -11,6 +11,7 @@ import datetime import re import time import tarfile +import os from ansible.module_utils.urls import fetch_file from ansible_collections.community.general.plugins.module_utils.redfish_utils import RedfishUtils @@ -79,19 +80,25 @@ class WdcRedfishUtils(RedfishUtils): return response return self._find_updateservice_additional_uris() - def _is_enclosure_multi_tenant(self): + def _is_enclosure_multi_tenant_and_fetch_gen(self): """Determine if the enclosure is multi-tenant. The serial number of a multi-tenant enclosure will end in "-A" or "-B". + Fetching enclsoure generation. - :return: True/False if the enclosure is multi-tenant or not; None if unable to determine. + :return: True/False if the enclosure is multi-tenant or not and return enclosure generation; + None if unable to determine. """ response = self.get_request(self.root_uri + self.service_root + "Chassis/Enclosure") if response['ret'] is False: return None pattern = r".*-[A,B]" data = response['data'] - return re.match(pattern, data['SerialNumber']) is not None + if 'EnclVersion' not in data: + enc_version = 'G1' + else: + enc_version = data['EnclVersion'] + return re.match(pattern, data['SerialNumber']) is not None, enc_version def _find_updateservice_additional_uris(self): """Find & set WDC-specific update service URIs""" @@ -180,15 +187,44 @@ class WdcRedfishUtils(RedfishUtils): To determine if the bundle is multi-tenant or not, it looks inside the .bin file within the tarfile, and checks the appropriate byte in the file. + If not tarfile, the bundle is checked for 2048th byte to determine whether it is Gen2 bundle. + Gen2 is always single tenant at this time. + :param str bundle_uri: HTTP URI of the firmware bundle. - :return: Firmware version number contained in the bundle, and whether or not the bundle is multi-tenant. - Either value will be None if unable to determine. + :return: Firmware version number contained in the bundle, whether or not the bundle is multi-tenant + and bundle generation. Either value will be None if unable to determine. :rtype: str or None, bool or None """ bundle_temp_filename = fetch_file(module=self.module, url=bundle_uri) + bundle_version = None + is_multi_tenant = None + gen = None + + # If not tarfile, then if the file has "MMG2" or "DPG2" at 2048th byte + # then the bundle is for MM or DP G2 if not tarfile.is_tarfile(bundle_temp_filename): - return None, None + cookie1 = None + with open(bundle_temp_filename, "rb") as bundle_file: + file_size = os.path.getsize(bundle_temp_filename) + if file_size >= 2052: + bundle_file.seek(2048) + cookie1 = bundle_file.read(4) + # It is anticipated that DP firmware bundle will be having the value "DPG2" + # for cookie1 in the header + if cookie1 and cookie1.decode("utf8") == "MMG2" or cookie1.decode("utf8") == "DPG2": + file_name, ext = os.path.splitext(str(bundle_uri.rsplit('/', 1)[1])) + # G2 bundle file name: Ultrastar-Data102_3000_SEP_1010-032_2.1.12 + parsedFileName = file_name.split('_') + if len(parsedFileName) == 5: + bundle_version = parsedFileName[4] + # MM G2 is always single tanant + is_multi_tenant = False + gen = "G2" + + return bundle_version, is_multi_tenant, gen + + # Bundle is for MM or DP G1 tf = tarfile.open(bundle_temp_filename) pattern_pkg = r"oobm-(.+)\.pkg" pattern_bin = r"(.*\.bin)" @@ -205,8 +241,9 @@ class WdcRedfishUtils(RedfishUtils): bin_file.seek(11) byte_11 = bin_file.read(1) is_multi_tenant = byte_11 == b'\x80' + gen = "G1" - return bundle_version, is_multi_tenant + return bundle_version, is_multi_tenant, gen @staticmethod def uri_is_http(uri): @@ -267,15 +304,16 @@ class WdcRedfishUtils(RedfishUtils): # Check the FW version in the bundle file, and compare it to what is already on the IOMs # Bundle version number - bundle_firmware_version, is_bundle_multi_tenant = self._get_bundle_version(bundle_uri) - if bundle_firmware_version is None or is_bundle_multi_tenant is None: + bundle_firmware_version, is_bundle_multi_tenant, bundle_gen = self._get_bundle_version(bundle_uri) + if bundle_firmware_version is None or is_bundle_multi_tenant is None or bundle_gen is None: return { 'ret': False, - 'msg': 'Unable to extract bundle version or multi-tenant status from update image tarfile' + 'msg': 'Unable to extract bundle version or multi-tenant status or generation from update image file' } + is_enclosure_multi_tenant, enclosure_gen = self._is_enclosure_multi_tenant_and_fetch_gen() + # Verify that the bundle is correctly multi-tenant or not - is_enclosure_multi_tenant = self._is_enclosure_multi_tenant() if is_enclosure_multi_tenant != is_bundle_multi_tenant: return { 'ret': False, @@ -285,6 +323,16 @@ class WdcRedfishUtils(RedfishUtils): ) } + # Verify that the bundle is compliant with the target enclosure + if enclosure_gen != bundle_gen: + return { + 'ret': False, + 'msg': 'Enclosure generation is {0} but bundle is of {1}'.format( + enclosure_gen, + bundle_gen, + ) + } + # Version number installed on IOMs firmware_inventory = self.get_firmware_inventory() if not firmware_inventory["ret"]: diff --git a/plugins/modules/aix_filesystem.py b/plugins/modules/aix_filesystem.py index 6abf6317f2..4a3775c672 100644 --- a/plugins/modules/aix_filesystem.py +++ b/plugins/modules/aix_filesystem.py @@ -242,7 +242,7 @@ def _validate_vg(module, vg): if rc != 0: module.fail_json(msg="Failed executing %s command." % lsvg_cmd) - rc, current_all_vgs, err = module.run_command([lsvg_cmd, "%s"]) + rc, current_all_vgs, err = module.run_command([lsvg_cmd]) if rc != 0: module.fail_json(msg="Failed executing %s command." % lsvg_cmd) diff --git a/plugins/modules/aix_inittab.py b/plugins/modules/aix_inittab.py index d4c9aa0b56..79336bab8d 100644 --- a/plugins/modules/aix_inittab.py +++ b/plugins/modules/aix_inittab.py @@ -192,6 +192,7 @@ def main(): rmitab = module.get_bin_path('rmitab') chitab = module.get_bin_path('chitab') rc = 0 + err = None # check if the new entry exists current_entry = check_current_entry(module) diff --git a/plugins/modules/aix_lvol.py b/plugins/modules/aix_lvol.py index 1e7b425687..7d0fb1ee09 100644 --- a/plugins/modules/aix_lvol.py +++ b/plugins/modules/aix_lvol.py @@ -240,8 +240,6 @@ def main(): state = module.params['state'] pvs = module.params['pvs'] - pv_list = ' '.join(pvs) - if policy == 'maximum': lv_policy = 'x' else: @@ -249,16 +247,16 @@ def main(): # Add echo command when running in check-mode if module.check_mode: - test_opt = 'echo ' + test_opt = [module.get_bin_path("echo", required=True)] else: - test_opt = '' + test_opt = [] # check if system commands are available lsvg_cmd = module.get_bin_path("lsvg", required=True) lslv_cmd = module.get_bin_path("lslv", required=True) # Get information on volume group requested - rc, vg_info, err = module.run_command("%s %s" % (lsvg_cmd, vg)) + rc, vg_info, err = module.run_command([lsvg_cmd, vg]) if rc != 0: if state == 'absent': @@ -273,8 +271,7 @@ def main(): lv_size = round_ppsize(convert_size(module, size), base=this_vg['pp_size']) # Get information on logical volume requested - rc, lv_info, err = module.run_command( - "%s %s" % (lslv_cmd, lv)) + rc, lv_info, err = module.run_command([lslv_cmd, lv]) if rc != 0: if state == 'absent': @@ -296,7 +293,7 @@ def main(): # create LV mklv_cmd = module.get_bin_path("mklv", required=True) - cmd = "%s %s -t %s -y %s -c %s -e %s %s %s %sM %s" % (test_opt, mklv_cmd, lv_type, lv, copies, lv_policy, opts, vg, lv_size, pv_list) + cmd = test_opt + [mklv_cmd, "-t", lv_type, "-y", lv, "-c", copies, "-e", lv_policy, opts, vg, "%sM" % (lv_size, )] + pvs rc, out, err = module.run_command(cmd) if rc == 0: module.exit_json(changed=True, msg="Logical volume %s created." % lv) @@ -306,7 +303,7 @@ def main(): if state == 'absent': # remove LV rmlv_cmd = module.get_bin_path("rmlv", required=True) - rc, out, err = module.run_command("%s %s -f %s" % (test_opt, rmlv_cmd, this_lv['name'])) + rc, out, err = module.run_command(test_opt + [rmlv_cmd, "-f", this_lv['name']]) if rc == 0: module.exit_json(changed=True, msg="Logical volume %s deleted." % lv) else: @@ -315,7 +312,7 @@ def main(): if this_lv['policy'] != policy: # change lv allocation policy chlv_cmd = module.get_bin_path("chlv", required=True) - rc, out, err = module.run_command("%s %s -e %s %s" % (test_opt, chlv_cmd, lv_policy, this_lv['name'])) + rc, out, err = module.run_command(test_opt + [chlv_cmd, "-e", lv_policy, this_lv['name']]) if rc == 0: module.exit_json(changed=True, msg="Logical volume %s policy changed: %s." % (lv, policy)) else: @@ -331,7 +328,7 @@ def main(): # resize LV based on absolute values if int(lv_size) > this_lv['size']: extendlv_cmd = module.get_bin_path("extendlv", required=True) - cmd = "%s %s %s %sM" % (test_opt, extendlv_cmd, lv, lv_size - this_lv['size']) + cmd = test_opt + [extendlv_cmd, lv, "%sM" % (lv_size - this_lv['size'], )] rc, out, err = module.run_command(cmd) if rc == 0: module.exit_json(changed=True, msg="Logical volume %s size extended to %sMB." % (lv, lv_size)) diff --git a/plugins/modules/alternatives.py b/plugins/modules/alternatives.py index 0d1b1e8cbe..da578276fa 100644 --- a/plugins/modules/alternatives.py +++ b/plugins/modules/alternatives.py @@ -344,7 +344,7 @@ class AlternativesModule(object): subcmd_path_map = dict(subcmd_path_link_regex.findall(display_output)) if not subcmd_path_map and self.subcommands: - subcmd_path_map = dict((s['name'], s['link']) for s in self.subcommands) + subcmd_path_map = {s['name']: s['link'] for s in self.subcommands} for path, prio, subcmd in alternative_regex.findall(display_output): self.current_alternatives[path] = dict( diff --git a/plugins/modules/ansible_galaxy_install.py b/plugins/modules/ansible_galaxy_install.py index 3b0a8fd47b..b0f3aeb5da 100644 --- a/plugins/modules/ansible_galaxy_install.py +++ b/plugins/modules/ansible_galaxy_install.py @@ -32,6 +32,19 @@ attributes: diff_mode: support: none options: + state: + description: + - > + If O(state=present) then the collection or role will be installed. + Note that the collections and roles are not updated with this option. + - > + Currently the O(state=latest) is ignored unless O(type=collection), and it will + ensure the collection is installed and updated to the latest available version. + - Please note that O(force=true) can be used to perform upgrade regardless of O(type). + type: str + choices: [ present, latest ] + default: present + version_added: 9.1.0 type: description: - The type of installation performed by C(ansible-galaxy). @@ -69,20 +82,11 @@ options: default: false force: description: - - Force overwriting an existing role or collection. + - Force overwriting existing roles and/or collections. + - It can be used for upgrading, but the module output will always report C(changed=true). - Using O(force=true) is mandatory when downgrading. type: bool default: false - ack_ansible29: - description: - - This option has no longer any effect and will be removed in community.general 9.0.0. - type: bool - default: false - ack_min_ansiblecore211: - description: - - This option has no longer any effect and will be removed in community.general 9.0.0. - type: bool - default: false """ EXAMPLES = """ @@ -181,7 +185,7 @@ RETURN = """ import re -from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt as fmt +from ansible_collections.community.general.plugins.module_utils.cmd_runner import CmdRunner, cmd_runner_fmt from ansible_collections.community.general.plugins.module_utils.module_helper import ModuleHelper, ModuleHelperException @@ -190,47 +194,40 @@ class AnsibleGalaxyInstall(ModuleHelper): _RE_LIST_PATH = re.compile(r'^# (?P.*)$') _RE_LIST_COLL = re.compile(r'^(?P\w+\.\w+)\s+(?P[\d\.]+)\s*$') _RE_LIST_ROLE = re.compile(r'^- (?P\w+\.\w+),\s+(?P[\d\.]+)\s*$') - _RE_INSTALL_OUTPUT = None # Set after determining ansible version, see __init_module__() + _RE_INSTALL_OUTPUT = re.compile( + r'^(?:(?P\w+\.\w+):(?P[\d\.]+)|- (?P\w+\.\w+) \((?P[\d\.]+)\)) was installed successfully$' + ) ansible_version = None output_params = ('type', 'name', 'dest', 'requirements_file', 'force', 'no_deps') module = dict( argument_spec=dict( + state=dict(type='str', choices=['present', 'latest'], default='present'), type=dict(type='str', choices=('collection', 'role', 'both'), required=True), name=dict(type='str'), requirements_file=dict(type='path'), dest=dict(type='path'), force=dict(type='bool', default=False), no_deps=dict(type='bool', default=False), - ack_ansible29=dict( - type='bool', - default=False, - removed_in_version='9.0.0', - removed_from_collection='community.general', - ), - ack_min_ansiblecore211=dict( - type='bool', - default=False, - removed_in_version='9.0.0', - removed_from_collection='community.general', - ), ), mutually_exclusive=[('name', 'requirements_file')], required_one_of=[('name', 'requirements_file')], required_if=[('type', 'both', ['requirements_file'])], supports_check_mode=False, ) + use_old_vardict = False command = 'ansible-galaxy' command_args_formats = dict( - type=fmt.as_func(lambda v: [] if v == 'both' else [v]), - galaxy_cmd=fmt.as_list(), - requirements_file=fmt.as_opt_val('-r'), - dest=fmt.as_opt_val('-p'), - force=fmt.as_bool("--force"), - no_deps=fmt.as_bool("--no-deps"), - version=fmt.as_bool("--version"), - name=fmt.as_list(), + type=cmd_runner_fmt.as_func(lambda v: [] if v == 'both' else [v]), + galaxy_cmd=cmd_runner_fmt.as_list(), + upgrade=cmd_runner_fmt.as_bool("--upgrade"), + requirements_file=cmd_runner_fmt.as_opt_val('-r'), + dest=cmd_runner_fmt.as_opt_val('-p'), + force=cmd_runner_fmt.as_bool("--force"), + no_deps=cmd_runner_fmt.as_bool("--no-deps"), + version=cmd_runner_fmt.as_fixed("--version"), + name=cmd_runner_fmt.as_list(), ) def _make_runner(self, lang): @@ -254,25 +251,16 @@ class AnsibleGalaxyInstall(ModuleHelper): try: runner = self._make_runner("C.UTF-8") with runner("version", check_rc=False, output_process=process) as ctx: - return runner, ctx.run(version=True) - except UnsupportedLocale as e: + return runner, ctx.run() + except UnsupportedLocale: runner = self._make_runner("en_US.UTF-8") with runner("version", check_rc=True, output_process=process) as ctx: - return runner, ctx.run(version=True) + return runner, ctx.run() def __init_module__(self): - # self.runner = CmdRunner(self.module, command=self.command, arg_formats=self.command_args_formats, force_lang=self.force_lang) self.runner, self.ansible_version = self._get_ansible_galaxy_version() if self.ansible_version < (2, 11): - self.module.fail_json( - msg="Support for Ansible 2.9 and ansible-base 2.10 has ben removed." - ) - # Collection install output changed: - # ansible-base 2.10: "coll.name (x.y.z)" - # ansible-core 2.11+: "coll.name:x.y.z" - self._RE_INSTALL_OUTPUT = re.compile(r'^(?:(?P\w+\.\w+)(?: \(|:)(?P[\d\.]+)\)?' - r'|- (?P\w+\.\w+) \((?P[\d\.]+)\))' - r' was installed successfully$') + self.module.fail_json(msg="Support for Ansible 2.9 and ansible-base 2.10 has been removed.") self.vars.set("new_collections", {}, change=True) self.vars.set("new_roles", {}, change=True) if self.vars.type != "collection": @@ -325,8 +313,9 @@ class AnsibleGalaxyInstall(ModuleHelper): elif match.group("role"): self.vars.new_roles[match.group("role")] = match.group("rversion") - with self.runner("type galaxy_cmd force no_deps dest requirements_file name", output_process=process) as ctx: - ctx.run(galaxy_cmd="install") + upgrade = (self.vars.type == "collection" and self.vars.state == "latest") + with self.runner("type galaxy_cmd upgrade force no_deps dest requirements_file name", output_process=process) as ctx: + ctx.run(galaxy_cmd="install", upgrade=upgrade) if self.verbosity > 2: self.vars.set("run_info", ctx.run_info) diff --git a/plugins/modules/apache2_mod_proxy.py b/plugins/modules/apache2_mod_proxy.py index 8f561e8ae0..786089d13c 100644 --- a/plugins/modules/apache2_mod_proxy.py +++ b/plugins/modules/apache2_mod_proxy.py @@ -277,7 +277,7 @@ class BalancerMember(object): for valuesset in subsoup[1::1]: if re.search(pattern=self.host, string=str(valuesset)): values = valuesset.findAll('td') - return dict((keys[x].string, values[x].string) for x in range(0, len(keys))) + return {keys[x].string: values[x].string for x in range(0, len(keys))} def get_member_status(self): """ Returns a dictionary of a balancer member's status attributes.""" @@ -286,7 +286,7 @@ class BalancerMember(object): 'hot_standby': 'Stby', 'ignore_errors': 'Ign'} actual_status = str(self.attributes['Status']) - status = dict((mode, patt in actual_status) for mode, patt in iteritems(status_mapping)) + status = {mode: patt in actual_status for mode, patt in iteritems(status_mapping)} return status def set_member_status(self, values): diff --git a/plugins/modules/apt_rpm.py b/plugins/modules/apt_rpm.py index de1b574114..3a0b6d805f 100644 --- a/plugins/modules/apt_rpm.py +++ b/plugins/modules/apt_rpm.py @@ -37,7 +37,17 @@ options: state: description: - Indicates the desired package state. - choices: [ absent, present, installed, removed ] + - Please note that V(present) and V(installed) are equivalent to V(latest) right now. + This will change in the future. To simply ensure that a package is installed, without upgrading + it, use the V(present_not_latest) state. + - The states V(latest) and V(present_not_latest) have been added in community.general 8.6.0. + choices: + - absent + - present + - present_not_latest + - installed + - removed + - latest default: present type: str update_cache: @@ -160,7 +170,7 @@ def local_rpm_package_name(path): def query_package(module, name): # rpm -q returns 0 if the package is installed, # 1 if it is not installed - rc, out, err = module.run_command("%s -q %s" % (RPM_PATH, name)) + rc, out, err = module.run_command([RPM_PATH, "-q", name]) if rc == 0: return True else: @@ -180,7 +190,7 @@ def check_package_version(module, name): return False -def query_package_provides(module, name): +def query_package_provides(module, name, allow_upgrade=False): # rpm -q returns 0 if the package is installed, # 1 if it is not installed if name.endswith('.rpm'): @@ -193,12 +203,13 @@ def query_package_provides(module, name): name = local_rpm_package_name(name) - rc, out, err = module.run_command("%s -q --provides %s" % (RPM_PATH, name)) + rc, out, err = module.run_command([RPM_PATH, "-q", "--provides", name]) if rc == 0: + if not allow_upgrade: + return True if check_package_version(module, name): return True - else: - return False + return False def update_package_db(module): @@ -242,7 +253,7 @@ def remove_packages(module, packages): if not query_package(module, package): continue - rc, out, err = module.run_command("%s -y remove %s" % (APT_PATH, package), environ_update={"LANG": "C"}) + rc, out, err = module.run_command([APT_PATH, "-y", "remove", package], environ_update={"LANG": "C"}) if rc != 0: module.fail_json(msg="failed to remove %s: %s" % (package, err)) @@ -255,28 +266,28 @@ def remove_packages(module, packages): return (False, "package(s) already absent") -def install_packages(module, pkgspec): +def install_packages(module, pkgspec, allow_upgrade=False): if pkgspec is None: return (False, "Empty package list") - packages = "" + packages = [] for package in pkgspec: - if not query_package_provides(module, package): - packages += "'%s' " % package + if not query_package_provides(module, package, allow_upgrade=allow_upgrade): + packages.append(package) - if len(packages) != 0: - - rc, out, err = module.run_command("%s -y install %s" % (APT_PATH, packages), environ_update={"LANG": "C"}) + if packages: + command = [APT_PATH, "-y", "install"] + packages + rc, out, err = module.run_command(command, environ_update={"LANG": "C"}) installed = True - for packages in pkgspec: - if not query_package_provides(module, package): + for package in pkgspec: + if not query_package_provides(module, package, allow_upgrade=False): installed = False # apt-rpm always have 0 for exit code if --force is used if rc or not installed: - module.fail_json(msg="'apt-get -y install %s' failed: %s" % (packages, err)) + module.fail_json(msg="'%s' failed: %s" % (" ".join(command), err)) else: return (True, "%s present(s)" % packages) else: @@ -286,7 +297,7 @@ def install_packages(module, pkgspec): def main(): module = AnsibleModule( argument_spec=dict( - state=dict(type='str', default='present', choices=['absent', 'installed', 'present', 'removed']), + state=dict(type='str', default='present', choices=['absent', 'installed', 'present', 'removed', 'present_not_latest', 'latest']), update_cache=dict(type='bool', default=False), clean=dict(type='bool', default=False), dist_upgrade=dict(type='bool', default=False), @@ -299,6 +310,18 @@ def main(): module.fail_json(msg="cannot find /usr/bin/apt-get and/or /usr/bin/rpm") p = module.params + if p['state'] in ['installed', 'present']: + module.deprecate( + 'state=%s currently behaves unexpectedly by always upgrading to the latest version if' + ' the package is already installed. This behavior is deprecated and will change in' + ' community.general 11.0.0. You can use state=latest to explicitly request this behavior' + ' or state=present_not_latest to explicitly request the behavior that state=%s will have' + ' in community.general 11.0.0, namely that the package will not be upgraded if it is' + ' already installed.' % (p['state'], p['state']), + version='11.0.0', + collection_name='community.general', + ) + modified = False output = "" @@ -320,8 +343,8 @@ def main(): output += out packages = p['package'] - if p['state'] in ['installed', 'present']: - (m, out) = install_packages(module, packages) + if p['state'] in ['installed', 'present', 'present_not_latest', 'latest']: + (m, out) = install_packages(module, packages, allow_upgrade=p['state'] != 'present_not_latest') modified = modified or m output += out diff --git a/plugins/modules/bootc_manage.py b/plugins/modules/bootc_manage.py new file mode 100644 index 0000000000..5628ffcca0 --- /dev/null +++ b/plugins/modules/bootc_manage.py @@ -0,0 +1,95 @@ +#!/usr/bin/python + +# Copyright (c) 2024, Ryan Cook +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt +# or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: bootc_manage +version_added: 9.3.0 +author: +- Ryan Cook (@cooktheryan) +short_description: Bootc Switch and Upgrade +description: + - This module manages the switching and upgrading of C(bootc). +options: + state: + description: + - 'Control to apply the latest image or switch the image.' + - 'B(Note:) This will not reboot the system.' + - 'Please use M(ansible.builtin.reboot) to reboot the system.' + required: true + type: str + choices: ['switch', 'latest'] + image: + description: + - 'The image to switch to.' + - 'This is required when O(state=switch).' + required: false + type: str + +''' + +EXAMPLES = ''' +# Switch to a different image +- name: Provide image to switch to a different image and retain the current running image + community.general.bootc_manage: + state: switch + image: "example.com/image:latest" + +# Apply updates of the current running image +- name: Apply updates of the current running image + community.general.bootc_manage: + state: latest +''' + +RETURN = ''' +''' + + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.locale import get_best_parsable_locale + + +def main(): + argument_spec = dict( + state=dict(type='str', required=True, choices=['switch', 'latest']), + image=dict(type='str', required=False), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ('state', 'switch', ['image']), + ], + ) + + state = module.params['state'] + image = module.params['image'] + + if state == 'switch': + command = ['bootc', 'switch', image, '--retain'] + elif state == 'latest': + command = ['bootc', 'upgrade'] + + locale = get_best_parsable_locale(module) + module.run_command_environ_update = dict(LANG=locale, LC_ALL=locale, LC_MESSAGES=locale, LC_CTYPE=locale, LANGUAGE=locale) + rc, stdout, err = module.run_command(command, check_rc=True) + + if 'Queued for next boot: ' in stdout: + result = {'changed': True, 'stdout': stdout} + module.exit_json(**result) + elif 'No changes in ' in stdout or 'Image specification is unchanged.' in stdout: + result = {'changed': False, 'stdout': stdout} + module.exit_json(**result) + else: + result = {'changed': False, 'stderr': err} + module.fail_json(msg='ERROR: Command execution failed.', **result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/btrfs_subvolume.py b/plugins/modules/btrfs_subvolume.py index 864bb65a66..35327bfe02 100644 --- a/plugins/modules/btrfs_subvolume.py +++ b/plugins/modules/btrfs_subvolume.py @@ -572,10 +572,7 @@ class BtrfsSubvolumeModule(object): self.__temporary_mounts[cache_key] = mountpoint mount = self.module.get_bin_path("mount", required=True) - command = "%s -o noatime,subvolid=%d %s %s " % (mount, - subvolid, - device, - mountpoint) + command = [mount, "-o", "noatime,subvolid=%d" % subvolid, device, mountpoint] result = self.module.run_command(command, check_rc=True) return mountpoint @@ -586,10 +583,10 @@ class BtrfsSubvolumeModule(object): def __cleanup_mount(self, mountpoint): umount = self.module.get_bin_path("umount", required=True) - result = self.module.run_command("%s %s" % (umount, mountpoint)) + result = self.module.run_command([umount, mountpoint]) if result[0] == 0: rmdir = self.module.get_bin_path("rmdir", required=True) - self.module.run_command("%s %s" % (rmdir, mountpoint)) + self.module.run_command([rmdir, mountpoint]) # Format and return results def get_results(self): diff --git a/plugins/modules/cargo.py b/plugins/modules/cargo.py index ba9c05ed7b..2fc729da20 100644 --- a/plugins/modules/cargo.py +++ b/plugins/modules/cargo.py @@ -1,6 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2021 Radek Sprta +# Copyright (c) 2024 Colin Nolan # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later @@ -65,6 +66,13 @@ options: type: str default: present choices: [ "present", "absent", "latest" ] + directory: + description: + - Path to the source directory to install the Rust package from. + - This is only used when installing packages. + type: path + required: false + version_added: 9.1.0 requirements: - cargo installed """ @@ -98,8 +106,14 @@ EXAMPLES = r""" community.general.cargo: name: ludusavi state: latest + +- name: Install "ludusavi" Rust package from source directory + community.general.cargo: + name: ludusavi + directory: /path/to/ludusavi/source """ +import json import os import re @@ -115,6 +129,7 @@ class Cargo(object): self.state = kwargs["state"] self.version = kwargs["version"] self.locked = kwargs["locked"] + self.directory = kwargs["directory"] @property def path(self): @@ -143,7 +158,7 @@ class Cargo(object): data, dummy = self._exec(cmd, True, False, False) - package_regex = re.compile(r"^([\w\-]+) v(.+):$") + package_regex = re.compile(r"^([\w\-]+) v(\S+).*:$") installed = {} for line in data.splitlines(): package_info = package_regex.match(line) @@ -163,19 +178,53 @@ class Cargo(object): if self.version: cmd.append("--version") cmd.append(self.version) + if self.directory: + cmd.append("--path") + cmd.append(self.directory) return self._exec(cmd) def is_outdated(self, name): installed_version = self.get_installed().get(name) + latest_version = ( + self.get_latest_published_version(name) + if not self.directory + else self.get_source_directory_version(name) + ) + return installed_version != latest_version + def get_latest_published_version(self, name): cmd = ["search", name, "--limit", "1"] data, dummy = self._exec(cmd, True, False, False) match = re.search(r'"(.+)"', data) - if match: - latest_version = match.group(1) + if not match: + self.module.fail_json( + msg="No published version for package %s found" % name + ) + return match.group(1) - return installed_version != latest_version + def get_source_directory_version(self, name): + cmd = [ + "metadata", + "--format-version", + "1", + "--no-deps", + "--manifest-path", + os.path.join(self.directory, "Cargo.toml"), + ] + data, dummy = self._exec(cmd, True, False, False) + manifest = json.loads(data) + + package = next( + (package for package in manifest["packages"] if package["name"] == name), + None, + ) + if not package: + self.module.fail_json( + msg="Package %s not defined in source, found: %s" + % (name, [x["name"] for x in manifest["packages"]]) + ) + return package["version"] def uninstall(self, packages=None): cmd = ["uninstall"] @@ -191,16 +240,21 @@ def main(): state=dict(default="present", choices=["present", "absent", "latest"]), version=dict(default=None, type="str"), locked=dict(default=False, type="bool"), + directory=dict(default=None, type="path"), ) module = AnsibleModule(argument_spec=arg_spec, supports_check_mode=True) name = module.params["name"] state = module.params["state"] version = module.params["version"] + directory = module.params["directory"] if not name: module.fail_json(msg="Package name must be specified") + if directory is not None and not os.path.isdir(directory): + module.fail_json(msg="Source directory does not exist") + # Set LANG env since we parse stdout module.run_command_environ_update = dict( LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C" diff --git a/plugins/modules/cobbler_sync.py b/plugins/modules/cobbler_sync.py index 4ec87c96c7..27f57028be 100644 --- a/plugins/modules/cobbler_sync.py +++ b/plugins/modules/cobbler_sync.py @@ -75,13 +75,16 @@ RETURN = r''' # Default return values ''' -import datetime import ssl from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six.moves import xmlrpc_client from ansible.module_utils.common.text.converters import to_text +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + def main(): module = AnsibleModule( @@ -110,7 +113,7 @@ def main(): changed=True, ) - start = datetime.datetime.utcnow() + start = now() ssl_context = None if not validate_certs: @@ -142,7 +145,7 @@ def main(): except Exception as e: module.fail_json(msg="Failed to sync Cobbler. {error}".format(error=to_text(e))) - elapsed = datetime.datetime.utcnow() - start + elapsed = now() - start module.exit_json(elapsed=elapsed.seconds, **result) diff --git a/plugins/modules/cobbler_system.py b/plugins/modules/cobbler_system.py index cecc02f717..a327ede84b 100644 --- a/plugins/modules/cobbler_system.py +++ b/plugins/modules/cobbler_system.py @@ -152,7 +152,6 @@ system: type: dict ''' -import datetime import ssl from ansible.module_utils.basic import AnsibleModule @@ -160,6 +159,10 @@ from ansible.module_utils.six import iteritems from ansible.module_utils.six.moves import xmlrpc_client from ansible.module_utils.common.text.converters import to_text +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + IFPROPS_MAPPING = dict( bondingopts='bonding_opts', bridgeopts='bridge_opts', @@ -232,7 +235,7 @@ def main(): changed=False, ) - start = datetime.datetime.utcnow() + start = now() ssl_context = None if not validate_certs: @@ -340,7 +343,7 @@ def main(): if module._diff: result['diff'] = dict(before=system, after=result['system']) - elapsed = datetime.datetime.utcnow() - start + elapsed = now() - start module.exit_json(elapsed=elapsed.seconds, **result) diff --git a/plugins/modules/consul_acl.py b/plugins/modules/consul_acl.py index 4617090fd3..2d60af0625 100644 --- a/plugins/modules/consul_acl.py +++ b/plugins/modules/consul_acl.py @@ -273,8 +273,8 @@ def set_acl(consul_client, configuration): :return: the output of setting the ACL """ acls_as_json = decode_acls_as_json(consul_client.acl.list()) - existing_acls_mapped_by_name = dict((acl.name, acl) for acl in acls_as_json if acl.name is not None) - existing_acls_mapped_by_token = dict((acl.token, acl) for acl in acls_as_json) + existing_acls_mapped_by_name = {acl.name: acl for acl in acls_as_json if acl.name is not None} + existing_acls_mapped_by_token = {acl.token: acl for acl in acls_as_json} if None in existing_acls_mapped_by_token: raise AssertionError("expecting ACL list to be associated to a token: %s" % existing_acls_mapped_by_token[None]) diff --git a/plugins/modules/consul_agent_check.py b/plugins/modules/consul_agent_check.py new file mode 100644 index 0000000000..3739260049 --- /dev/null +++ b/plugins/modules/consul_agent_check.py @@ -0,0 +1,254 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Michael Ilg +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = ''' +module: consul_agent_check +short_description: Add, modify, and delete checks within a consul cluster +version_added: 9.1.0 +description: + - Allows the addition, modification and deletion of checks in a consul + cluster via the agent. For more details on using and configuring Checks, + see U(https://developer.hashicorp.com/consul/api-docs/agent/check). + - Currently, there is no complete way to retrieve the script, interval or TTL + metadata for a registered check. Without this metadata it is not possible to + tell if the data supplied with ansible represents a change to a check. As a + result this does not attempt to determine changes and will always report a + changed occurred. An API method is planned to supply this metadata so at that + stage change management will be added. +author: + - Michael Ilg (@Ilgmi) +extends_documentation_fragment: + - community.general.consul + - community.general.consul.actiongroup_consul + - community.general.consul.token + - community.general.attributes +attributes: + check_mode: + support: full + details: + - The result is the object as it is defined in the module options and not the object structure of the consul API. + For a better overview of what the object structure looks like, + take a look at U(https://developer.hashicorp.com/consul/api-docs/agent/check#list-checks). + diff_mode: + support: partial + details: + - In check mode the diff will show the object as it is defined in the module options and not the object structure of the consul API. +options: + state: + description: + - Whether the check should be present or absent. + choices: ['present', 'absent'] + default: present + type: str + name: + description: + - Required name for the service check. + type: str + id: + description: + - Specifies a unique ID for this check on the node. This defaults to the O(name) parameter, but it may be necessary to provide + an ID for uniqueness. This value will return in the response as "CheckId". + type: str + interval: + description: + - The interval at which the service check will be run. + This is a number with a V(s) or V(m) suffix to signify the units of seconds or minutes, for example V(15s) or V(1m). + If no suffix is supplied V(s) will be used by default, for example V(10) will be V(10s). + - Required if one of the parameters O(args), O(http), or O(tcp) is specified. + type: str + notes: + description: + - Notes to attach to check when registering it. + type: str + args: + description: + - Specifies command arguments to run to update the status of the check. + - Requires O(interval) to be provided. + - Mutually exclusive with O(ttl), O(tcp) and O(http). + type: list + elements: str + ttl: + description: + - Checks can be registered with a TTL instead of a O(args) and O(interval) + this means that the service will check in with the agent before the + TTL expires. If it doesn't the check will be considered failed. + Required if registering a check and the script an interval are missing + Similar to the interval this is a number with a V(s) or V(m) suffix to + signify the units of seconds or minutes, for example V(15s) or V(1m). + If no suffix is supplied V(s) will be used by default, for example V(10) will be V(10s). + - Mutually exclusive with O(args), O(tcp) and O(http). + type: str + tcp: + description: + - Checks can be registered with a TCP port. This means that consul + will check if the connection attempt to that port is successful (that is, the port is currently accepting connections). + The format is V(host:port), for example V(localhost:80). + - Requires O(interval) to be provided. + - Mutually exclusive with O(args), O(ttl) and O(http). + type: str + version_added: '1.3.0' + http: + description: + - Checks can be registered with an HTTP endpoint. This means that consul + will check that the http endpoint returns a successful HTTP status. + - Requires O(interval) to be provided. + - Mutually exclusive with O(args), O(ttl) and O(tcp). + type: str + timeout: + description: + - A custom HTTP check timeout. The consul default is 10 seconds. + Similar to the interval this is a number with a V(s) or V(m) suffix to + signify the units of seconds or minutes, for example V(15s) or V(1m). + If no suffix is supplied V(s) will be used by default, for example V(10) will be V(10s). + type: str + service_id: + description: + - The ID for the service, must be unique per node. If O(state=absent), + defaults to the service name if supplied. + type: str +''' + +EXAMPLES = ''' +- name: Register tcp check for service 'nginx' + community.general.consul_agent_check: + name: nginx_tcp_check + service_id: nginx + interval: 60s + tcp: localhost:80 + notes: "Nginx Check" + +- name: Register http check for service 'nginx' + community.general.consul_agent_check: + name: nginx_http_check + service_id: nginx + interval: 60s + http: http://localhost:80/status + notes: "Nginx Check" + +- name: Remove check for service 'nginx' + community.general.consul_agent_check: + state: absent + id: nginx_http_check + service_id: "{{ nginx_service.ID }}" +''' + +RETURN = """ +check: + description: The check as returned by the consul HTTP API. + returned: always + type: dict + sample: + CheckID: nginx_check + ServiceID: nginx + Interval: 30s + Type: http + Notes: Nginx Check +operation: + description: The operation performed. + returned: changed + type: str + sample: update +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.consul import ( + AUTH_ARGUMENTS_SPEC, + OPERATION_CREATE, + OPERATION_UPDATE, + OPERATION_DELETE, + OPERATION_READ, + _ConsulModule, + validate_check, +) + +_ARGUMENT_SPEC = { + "state": dict(default="present", choices=["present", "absent"]), + "name": dict(type='str'), + "id": dict(type='str'), + "interval": dict(type='str'), + "notes": dict(type='str'), + "args": dict(type='list', elements='str'), + "http": dict(type='str'), + "tcp": dict(type='str'), + "ttl": dict(type='str'), + "timeout": dict(type='str'), + "service_id": dict(type='str'), +} + +_MUTUALLY_EXCLUSIVE = [ + ('args', 'ttl', 'tcp', 'http'), +] + +_REQUIRED_IF = [ + ('state', 'present', ['name']), + ('state', 'absent', ('id', 'name'), True), +] + +_REQUIRED_BY = { + 'args': 'interval', + 'http': 'interval', + 'tcp': 'interval', +} + +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) + + +class ConsulAgentCheckModule(_ConsulModule): + api_endpoint = "agent/check" + result_key = "check" + unique_identifiers = ["id", "name"] + operational_attributes = {"Node", "CheckID", "Output", "ServiceName", "ServiceTags", + "Status", "Type", "ExposedPort", "Definition"} + + def endpoint_url(self, operation, identifier=None): + if operation == OPERATION_READ: + return "agent/checks" + if operation in [OPERATION_CREATE, OPERATION_UPDATE]: + return "/".join([self.api_endpoint, "register"]) + if operation == OPERATION_DELETE: + return "/".join([self.api_endpoint, "deregister", identifier]) + + return super(ConsulAgentCheckModule, self).endpoint_url(operation, identifier) + + def read_object(self): + url = self.endpoint_url(OPERATION_READ) + checks = self.get(url) + identifier = self.id_from_obj(self.params) + if identifier in checks: + return checks[identifier] + return None + + def prepare_object(self, existing, obj): + existing = super(ConsulAgentCheckModule, self).prepare_object(existing, obj) + validate_check(existing) + return existing + + def delete_object(self, obj): + if not self._module.check_mode: + self.put(self.endpoint_url(OPERATION_DELETE, obj.get("CheckID"))) + return {} + + +def main(): + module = AnsibleModule( + _ARGUMENT_SPEC, + mutually_exclusive=_MUTUALLY_EXCLUSIVE, + required_if=_REQUIRED_IF, + required_by=_REQUIRED_BY, + supports_check_mode=True, + ) + + consul_module = ConsulAgentCheckModule(module) + consul_module.execute() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/consul_agent_service.py b/plugins/modules/consul_agent_service.py new file mode 100644 index 0000000000..a8ef098970 --- /dev/null +++ b/plugins/modules/consul_agent_service.py @@ -0,0 +1,289 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Michael Ilg +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = ''' +module: consul_agent_service +short_description: Add, modify and delete services within a consul cluster +version_added: 9.1.0 +description: + - Allows the addition, modification and deletion of services in a consul + cluster via the agent. + - There are currently no plans to create services and checks in one. + This is because the Consul API does not provide checks for a service and + the checks themselves do not match the module parameters. + Therefore, only a service without checks can be created in this module. +author: + - Michael Ilg (@Ilgmi) +extends_documentation_fragment: + - community.general.consul + - community.general.consul.actiongroup_consul + - community.general.consul.token + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: partial + details: + - In check mode the diff will miss operational attributes. +options: + state: + description: + - Whether the service should be present or absent. + choices: ['present', 'absent'] + default: present + type: str + name: + description: + - Unique name for the service on a node, must be unique per node, + required if registering a service. + type: str + id: + description: + - Specifies a unique ID for this service. This must be unique per agent. This defaults to the O(name) parameter if not provided. + If O(state=absent), defaults to the service name if supplied. + type: str + tags: + description: + - Tags that will be attached to the service registration. + type: list + elements: str + address: + description: + - The address to advertise that the service will be listening on. + This value will be passed as the C(address) parameter to Consul's + C(/v1/agent/service/register) API method, so refer to the Consul API + documentation for further details. + type: str + meta: + description: + - Optional meta data used for filtering. + For keys, the characters C(A-Z), C(a-z), C(0-9), C(_), C(-) are allowed. + Not allowed characters are replaced with underscores. + type: dict + service_port: + description: + - The port on which the service is listening. Can optionally be supplied for + registration of a service, that is if O(name) or O(id) is set. + type: int + enable_tag_override: + description: + - Specifies to disable the anti-entropy feature for this service's tags. + If EnableTagOverride is set to true then external agents can update this service in the catalog and modify the tags. + type: bool + default: False + weights: + description: + - Specifies weights for the service + type: dict + suboptions: + passing: + description: + - Weights for passing. + type: int + default: 1 + warning: + description: + - Weights for warning. + type: int + default: 1 + default: {"passing": 1, "warning": 1} +''' + +EXAMPLES = ''' +- name: Register nginx service with the local consul agent + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: nginx + service_port: 80 + +- name: Register nginx with a tcp check + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: nginx + service_port: 80 + +- name: Register nginx with an http check + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: nginx + service_port: 80 + +- name: Register external service nginx available at 10.1.5.23 + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: nginx + service_port: 80 + address: 10.1.5.23 + +- name: Register nginx with some service tags + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: nginx + service_port: 80 + tags: + - prod + - webservers + +- name: Register nginx with some service meta + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: nginx + service_port: 80 + meta: + nginx_version: 1.25.3 + +- name: Remove nginx service + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + service_id: nginx + state: absent + +- name: Register celery worker service + community.general.consul_agent_service: + host: consul1.example.com + token: some_management_acl + name: celery-worker + tags: + - prod + - worker +''' + +RETURN = """ +service: + description: The service as returned by the consul HTTP API. + returned: always + type: dict + sample: + ID: nginx + Service: nginx + Address: localhost + Port: 80 + Tags: + - http + Meta: + - nginx_version: 1.23.3 + Datacenter: dc1 + Weights: + Passing: 1 + Warning: 1 + ContentHash: 61a245cd985261ac + EnableTagOverride: false +operation: + description: The operation performed. + returned: changed + type: str + sample: update +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.consul import ( + AUTH_ARGUMENTS_SPEC, + OPERATION_CREATE, + OPERATION_UPDATE, + OPERATION_DELETE, + _ConsulModule +) + +_CHECK_MUTUALLY_EXCLUSIVE = [('args', 'ttl', 'tcp', 'http')] +_CHECK_REQUIRED_BY = { + 'args': 'interval', + 'http': 'interval', + 'tcp': 'interval', +} + +_ARGUMENT_SPEC = { + "state": dict(default="present", choices=["present", "absent"]), + "name": dict(type='str'), + "id": dict(type='str'), + "tags": dict(type='list', elements='str'), + "address": dict(type='str'), + "meta": dict(type='dict'), + "service_port": dict(type='int'), + "enable_tag_override": dict(type='bool', default=False), + "weights": dict(type='dict', options=dict( + passing=dict(type='int', default=1, no_log=False), + warning=dict(type='int', default=1) + ), default={"passing": 1, "warning": 1}) +} + +_REQUIRED_IF = [ + ('state', 'present', ['name']), + ('state', 'absent', ('id', 'name'), True), +] + +_ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) + + +class ConsulAgentServiceModule(_ConsulModule): + api_endpoint = "agent/service" + result_key = "service" + unique_identifiers = ["id", "name"] + operational_attributes = {"Service", "ContentHash", "Datacenter"} + + def endpoint_url(self, operation, identifier=None): + if operation in [OPERATION_CREATE, OPERATION_UPDATE]: + return "/".join([self.api_endpoint, "register"]) + if operation == OPERATION_DELETE: + return "/".join([self.api_endpoint, "deregister", identifier]) + + return super(ConsulAgentServiceModule, self).endpoint_url(operation, identifier) + + def prepare_object(self, existing, obj): + existing = super(ConsulAgentServiceModule, self).prepare_object(existing, obj) + if "ServicePort" in existing: + existing["Port"] = existing.pop("ServicePort") + + if "ID" not in existing: + existing["ID"] = existing["Name"] + + return existing + + def needs_update(self, api_obj, module_obj): + obj = {} + if "Service" in api_obj: + obj["Service"] = api_obj["Service"] + api_obj = self.prepare_object(api_obj, obj) + + if "Name" in module_obj: + module_obj["Service"] = module_obj.pop("Name") + if "ServicePort" in module_obj: + module_obj["Port"] = module_obj.pop("ServicePort") + + return super(ConsulAgentServiceModule, self).needs_update(api_obj, module_obj) + + def delete_object(self, obj): + if not self._module.check_mode: + url = self.endpoint_url(OPERATION_DELETE, self.id_from_obj(obj, camel_case=True)) + self.put(url) + return {} + + +def main(): + module = AnsibleModule( + _ARGUMENT_SPEC, + required_if=_REQUIRED_IF, + supports_check_mode=True, + ) + + consul_module = ConsulAgentServiceModule(module) + consul_module.execute() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/consul_auth_method.py b/plugins/modules/consul_auth_method.py index afe549f6ef..e28474c313 100644 --- a/plugins/modules/consul_auth_method.py +++ b/plugins/modules/consul_auth_method.py @@ -168,7 +168,7 @@ def normalize_ttl(ttl): class ConsulAuthMethodModule(_ConsulModule): api_endpoint = "acl/auth-method" result_key = "auth_method" - unique_identifier = "name" + unique_identifiers = ["name"] def map_param(self, k, v, is_update): if k == "config" and v: diff --git a/plugins/modules/consul_binding_rule.py b/plugins/modules/consul_binding_rule.py index 88496f8675..6a2882cee2 100644 --- a/plugins/modules/consul_binding_rule.py +++ b/plugins/modules/consul_binding_rule.py @@ -124,7 +124,7 @@ from ansible_collections.community.general.plugins.module_utils.consul import ( class ConsulBindingRuleModule(_ConsulModule): api_endpoint = "acl/binding-rule" result_key = "binding_rule" - unique_identifier = "id" + unique_identifiers = ["id"] def read_object(self): url = "acl/binding-rules?authmethod={0}".format(self.params["auth_method"]) diff --git a/plugins/modules/consul_policy.py b/plugins/modules/consul_policy.py index f020622a0c..36139ac097 100644 --- a/plugins/modules/consul_policy.py +++ b/plugins/modules/consul_policy.py @@ -33,6 +33,8 @@ attributes: version_added: 8.3.0 details: - In check mode the diff will miss operational attributes. + action_group: + version_added: 8.3.0 options: state: description: @@ -143,7 +145,7 @@ _ARGUMENT_SPEC.update(AUTH_ARGUMENTS_SPEC) class ConsulPolicyModule(_ConsulModule): api_endpoint = "acl/policy" result_key = "policy" - unique_identifier = "id" + unique_identifiers = ["id"] def endpoint_url(self, operation, identifier=None): if operation == OPERATION_READ: diff --git a/plugins/modules/consul_role.py b/plugins/modules/consul_role.py index 0da71507a6..d6c4e4dd92 100644 --- a/plugins/modules/consul_role.py +++ b/plugins/modules/consul_role.py @@ -32,6 +32,8 @@ attributes: details: - In check mode the diff will miss operational attributes. version_added: 8.3.0 + action_group: + version_added: 8.3.0 options: name: description: @@ -210,7 +212,7 @@ from ansible_collections.community.general.plugins.module_utils.consul import ( class ConsulRoleModule(_ConsulModule): api_endpoint = "acl/role" result_key = "role" - unique_identifier = "id" + unique_identifiers = ["id"] def endpoint_url(self, operation, identifier=None): if operation == OPERATION_READ: diff --git a/plugins/modules/consul_session.py b/plugins/modules/consul_session.py index bd03b561a7..87a5f19143 100644 --- a/plugins/modules/consul_session.py +++ b/plugins/modules/consul_session.py @@ -29,6 +29,8 @@ attributes: support: none diff_mode: support: none + action_group: + version_added: 8.3.0 options: id: description: diff --git a/plugins/modules/consul_token.py b/plugins/modules/consul_token.py index eee419863f..c8bc8bc279 100644 --- a/plugins/modules/consul_token.py +++ b/plugins/modules/consul_token.py @@ -31,6 +31,8 @@ attributes: support: partial details: - In check mode the diff will miss operational attributes. + action_group: + version_added: 8.3.0 options: state: description: @@ -233,13 +235,13 @@ def normalize_link_obj(api_obj, module_obj, key): class ConsulTokenModule(_ConsulModule): api_endpoint = "acl/token" result_key = "token" - unique_identifier = "accessor_id" + unique_identifiers = ["accessor_id"] create_only_fields = {"expiration_ttl"} def read_object(self): # if `accessor_id` is not supplied we can only create objects and are not idempotent - if not self.params.get(self.unique_identifier): + if not self.id_from_obj(self.params): return None return super(ConsulTokenModule, self).read_object() diff --git a/plugins/modules/copr.py b/plugins/modules/copr.py index 157a6c1605..809064114a 100644 --- a/plugins/modules/copr.py +++ b/plugins/modules/copr.py @@ -52,6 +52,18 @@ options: for example V(epel-7-x86_64). Default chroot is determined by the operating system, version of the operating system, and architecture on which the module is run. type: str + includepkgs: + description: List of packages to include. + required: false + type: list + elements: str + version_added: 9.4.0 + excludepkgs: + description: List of packages to exclude. + required: false + type: list + elements: str + version_added: 9.4.0 """ EXAMPLES = r""" @@ -255,6 +267,12 @@ class CoprModule(object): """ if not repo_content: repo_content = self._download_repo_info() + if self.ansible_module.params["includepkgs"]: + includepkgs_value = ','.join(self.ansible_module.params['includepkgs']) + repo_content = repo_content.rstrip('\n') + '\nincludepkgs={0}\n'.format(includepkgs_value) + if self.ansible_module.params["excludepkgs"]: + excludepkgs_value = ','.join(self.ansible_module.params['excludepkgs']) + repo_content = repo_content.rstrip('\n') + '\nexcludepkgs={0}\n'.format(excludepkgs_value) if self._compare_repo_content(repo_filename_path, repo_content): return False if not self.check_mode: @@ -470,6 +488,8 @@ def run_module(): name=dict(type="str", required=True), state=dict(type="str", choices=["enabled", "disabled", "absent"], default="enabled"), chroot=dict(type="str"), + includepkgs=dict(type='list', elements="str", required=False), + excludepkgs=dict(type='list', elements="str", required=False), ) module = AnsibleModule(argument_spec=module_args, supports_check_mode=True) params = module.params diff --git a/plugins/modules/cpanm.py b/plugins/modules/cpanm.py index 20ac3e7149..3beae895dc 100644 --- a/plugins/modules/cpanm.py +++ b/plugins/modules/cpanm.py @@ -68,9 +68,10 @@ options: mode: description: - Controls the module behavior. See notes below for more details. - - Default is V(compatibility) but that behavior is deprecated and will be changed to V(new) in community.general 9.0.0. + - The default changed from V(compatibility) to V(new) in community.general 9.0.0. type: str choices: [compatibility, new] + default: new version_added: 3.0.0 name_check: description: @@ -80,12 +81,16 @@ options: notes: - Please note that U(http://search.cpan.org/dist/App-cpanminus/bin/cpanm, cpanm) must be installed on the remote host. - "This module now comes with a choice of execution O(mode): V(compatibility) or V(new)." - - "O(mode=compatibility): When using V(compatibility) mode, the module will keep backward compatibility. This is the default mode. + - > + O(mode=compatibility): When using V(compatibility) mode, the module will keep backward compatibility. + This was the default mode before community.general 9.0.0. O(name) must be either a module name or a distribution file. If the perl module given by O(name) is installed (at the exact O(version) when specified), then nothing happens. Otherwise, it will be installed using the C(cpanm) executable. O(name) cannot be an URL, or a git URL. - C(cpanm) version specifiers do not work in this mode." - - "O(mode=new): When using V(new) mode, the module will behave differently. The O(name) parameter may refer to a module name, a distribution file, - a HTTP URL or a git repository URL as described in C(cpanminus) documentation. C(cpanm) version specifiers are recognized." + C(cpanm) version specifiers do not work in this mode. + - > + O(mode=new): When using V(new) mode, the module will behave differently. The O(name) parameter may refer to a module name, a distribution file, + a HTTP URL or a git repository URL as described in C(cpanminus) documentation. C(cpanm) version specifiers are recognized. + This is the default mode from community.general 9.0.0 onwards. author: - "Franck Cuny (@fcuny)" - "Alexei Znamensky (@russoz)" @@ -150,7 +155,7 @@ class CPANMinus(ModuleHelper): mirror_only=dict(type='bool', default=False), installdeps=dict(type='bool', default=False), executable=dict(type='path'), - mode=dict(type='str', choices=['compatibility', 'new']), + mode=dict(type='str', default='new', choices=['compatibility', 'new']), name_check=dict(type='str') ), required_one_of=[('name', 'from_path')], @@ -165,17 +170,10 @@ class CPANMinus(ModuleHelper): installdeps=cmd_runner_fmt.as_bool("--installdeps"), pkg_spec=cmd_runner_fmt.as_list(), ) + use_old_vardict = False def __init_module__(self): v = self.vars - if v.mode is None: - self.deprecate( - "The default value 'compatibility' for parameter 'mode' is being deprecated " - "and it will be replaced by 'new'", - version="9.0.0", - collection_name="community.general" - ) - v.mode = "compatibility" if v.mode == "compatibility": if v.name_check: self.do_raise("Parameter name_check can only be used with mode=new") diff --git a/plugins/modules/cronvar.py b/plugins/modules/cronvar.py index fdcbc7d24b..66fa175498 100644 --- a/plugins/modules/cronvar.py +++ b/plugins/modules/cronvar.py @@ -183,6 +183,7 @@ class CronVar(object): fileh = open(backup_file, 'w') elif self.cron_file: fileh = open(self.cron_file, 'w') + path = None else: filed, path = tempfile.mkstemp(prefix='crontab') fileh = os.fdopen(filed, 'w') diff --git a/plugins/modules/django_check.py b/plugins/modules/django_check.py new file mode 100644 index 0000000000..1553da7a30 --- /dev/null +++ b/plugins/modules/django_check.py @@ -0,0 +1,113 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = """ +module: django_check +author: + - Alexei Znamensky (@russoz) +short_description: Wrapper for C(django-admin check) +version_added: 9.1.0 +description: + - This module is a wrapper for the execution of C(django-admin check). +extends_documentation_fragment: + - community.general.attributes + - community.general.django +options: + database: + description: + - Specify databases to run checks against. + - If not specified, Django will not run database tests. + type: list + elements: str + deploy: + description: + - Include additional checks relevant in a deployment setting. + type: bool + default: false + fail_level: + description: + - Message level that will trigger failure. + - Default is the Django default value. Check the documentation for the version being used. + type: str + choices: [CRITICAL, ERROR, WARNING, INFO, DEBUG] + tags: + description: + - Restrict checks to specific tags. + type: list + elements: str + apps: + description: + - Restrict checks to specific applications. + - Default is to check all applications. + type: list + elements: str +notes: + - The outcome of the module is found in the common return values RV(ignore:stdout), RV(ignore:stderr), RV(ignore:rc). + - The module will fail if RV(ignore:rc) is not zero. +attributes: + check_mode: + support: full + diff_mode: + support: none +""" + +EXAMPLES = """ +- name: Check the entire project + community.general.django_check: + settings: myproject.settings + +- name: Create the project using specific databases + community.general.django_check: + database: + - somedb + - myotherdb + settings: fancysite.settings + pythonpath: /home/joedoe/project/fancysite + venv: /home/joedoe/project/fancysite/venv +""" + +RETURN = """ +run_info: + description: Command-line execution information. + type: dict + returned: success and C(verbosity) >= 3 +""" + +from ansible_collections.community.general.plugins.module_utils.django import DjangoModuleHelper +from ansible_collections.community.general.plugins.module_utils.cmd_runner import cmd_runner_fmt + + +class DjangoCheck(DjangoModuleHelper): + module = dict( + argument_spec=dict( + database=dict(type="list", elements="str"), + deploy=dict(type="bool", default=False), + fail_level=dict(type="str", choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]), + tags=dict(type="list", elements="str"), + apps=dict(type="list", elements="str"), + ), + supports_check_mode=True, + ) + arg_formats = dict( + database=cmd_runner_fmt.stack(cmd_runner_fmt.as_opt_val)("--database"), + deploy=cmd_runner_fmt.as_bool("--deploy"), + fail_level=cmd_runner_fmt.as_opt_val("--fail-level"), + tags=cmd_runner_fmt.stack(cmd_runner_fmt.as_opt_val)("--tag"), + apps=cmd_runner_fmt.as_list(), + ) + django_admin_cmd = "check" + django_admin_arg_order = "database deploy fail_level tags apps" + + +def main(): + DjangoCheck.execute() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/django_command.py b/plugins/modules/django_command.py new file mode 100644 index 0000000000..788f4a100e --- /dev/null +++ b/plugins/modules/django_command.py @@ -0,0 +1,83 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = """ +module: django_command +author: + - Alexei Znamensky (@russoz) +short_description: Run Django admin commands +version_added: 9.0.0 +description: + - This module allows the execution of arbitrary Django admin commands. +extends_documentation_fragment: + - community.general.attributes + - community.general.django +attributes: + check_mode: + support: none + diff_mode: + support: none +options: + command: + description: + - Django admin command. It must be a valid command accepted by C(python -m django) at the target system. + type: str + required: true + extra_args: + type: list + elements: str + description: + - List of extra arguments passed to the django admin command. +""" + +EXAMPLES = """ +- name: Check the project + community.general.django_command: + command: check + settings: myproject.settings + +- name: Check the project in specified python path, using virtual environment + community.general.django_command: + command: check + settings: fancysite.settings + pythonpath: /home/joedoe/project/fancysite + venv: /home/joedoe/project/fancysite/venv +""" + +RETURN = """ +run_info: + description: Command-line execution information. + type: dict + returned: success and O(verbosity) >= 3 +""" + +from ansible_collections.community.general.plugins.module_utils.django import DjangoModuleHelper +from ansible_collections.community.general.plugins.module_utils.cmd_runner import cmd_runner_fmt + + +class DjangoCommand(DjangoModuleHelper): + module = dict( + argument_spec=dict( + command=dict(type="str", required=True), + extra_args=dict(type="list", elements="str"), + ), + supports_check_mode=False, + ) + arg_formats = dict( + extra_args=cmd_runner_fmt.as_list(), + ) + django_admin_arg_order = "extra_args" + + +def main(): + DjangoCommand.execute() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/django_createcachetable.py b/plugins/modules/django_createcachetable.py new file mode 100644 index 0000000000..b038e0358f --- /dev/null +++ b/plugins/modules/django_createcachetable.py @@ -0,0 +1,67 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = """ +module: django_createcachetable +author: + - Alexei Znamensky (@russoz) +short_description: Wrapper for C(django-admin createcachetable) +version_added: 9.1.0 +description: + - This module is a wrapper for the execution of C(django-admin createcachetable). +extends_documentation_fragment: + - community.general.attributes + - community.general.django + - community.general.django.database +attributes: + check_mode: + support: full + diff_mode: + support: none +""" + +EXAMPLES = """ +- name: Create cache table in the default database + community.general.django_createcachetable: + settings: myproject.settings + +- name: Create cache table in the other database + community.general.django_createcachetable: + database: myotherdb + settings: fancysite.settings + pythonpath: /home/joedoe/project/fancysite + venv: /home/joedoe/project/fancysite/venv +""" + +RETURN = """ +run_info: + description: Command-line execution information. + type: dict + returned: success and O(verbosity) >= 3 +""" + +from ansible_collections.community.general.plugins.module_utils.django import DjangoModuleHelper + + +class DjangoCreateCacheTable(DjangoModuleHelper): + module = dict( + supports_check_mode=True, + ) + django_admin_cmd = "createcachetable" + django_admin_arg_order = "noinput database dry_run" + _django_args = ["noinput", "database", "dry_run"] + _check_mode_arg = "dry_run" + + +def main(): + DjangoCreateCacheTable.execute() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/django_manage.py b/plugins/modules/django_manage.py index 114ec0353e..352bfe4b50 100644 --- a/plugins/modules/django_manage.py +++ b/plugins/modules/django_manage.py @@ -28,23 +28,16 @@ options: command: description: - The name of the Django management command to run. The commands listed below are built in this module and have some basic parameter validation. - - > - V(cleanup) - clean up old data from the database (deprecated in Django 1.5). This parameter will be - removed in community.general 9.0.0. Use V(clearsessions) instead. - V(collectstatic) - Collects the static files into C(STATIC_ROOT). - V(createcachetable) - Creates the cache tables for use with the database cache backend. - V(flush) - Removes all data from the database. - V(loaddata) - Searches for and loads the contents of the named O(fixtures) into the database. - V(migrate) - Synchronizes the database state with models and migrations. - - > - V(syncdb) - Synchronizes the database state with models and migrations (deprecated in Django 1.7). - This parameter will be removed in community.general 9.0.0. Use V(migrate) instead. - V(test) - Runs tests for all installed apps. - - > - V(validate) - Validates all installed models (deprecated in Django 1.7). This parameter will be - removed in community.general 9.0.0. Use V(check) instead. - - Other commands can be entered, but will fail if they are unknown to Django. Other commands that may + - Other commands can be entered, but will fail if they are unknown to Django. Other commands that may prompt for user input should be run with the C(--noinput) flag. + - Support for the values V(cleanup), V(syncdb), V(validate) was removed in community.general 9.0.0. + See note about supported versions of Django. type: str required: true project_path: @@ -69,6 +62,7 @@ options: virtualenv: description: - An optional path to a C(virtualenv) installation to use while running the manage application. + - The virtual environment must exist, otherwise the module will fail. type: path aliases: [virtual_env] apps: @@ -132,31 +126,24 @@ options: aliases: [test_runner] ack_venv_creation_deprecation: description: - - >- - When a O(virtualenv) is set but the virtual environment does not exist, the current behavior is - to create a new virtual environment. That behavior is deprecated and if that case happens it will - generate a deprecation warning. Set this flag to V(true) to suppress the deprecation warning. - - Please note that you will receive no further warning about this being removed until the module - will start failing in such cases from community.general 9.0.0 on. + - This option no longer has any effect since community.general 9.0.0. + - It will be removed from community.general 11.0.0. type: bool version_added: 5.8.0 notes: - > - B(ATTENTION - DEPRECATION): Support for Django releases older than 4.1 will be removed in - community.general version 9.0.0 (estimated to be released in May 2024). - Please notice that Django 4.1 requires Python 3.8 or greater. - - C(virtualenv) (U(http://www.virtualenv.org)) must be installed on the remote host if the O(virtualenv) parameter - is specified. This requirement is deprecated and will be removed in community.general version 9.0.0. - - This module will create a virtualenv if the O(virtualenv) parameter is specified and a virtual environment does not already - exist at the given location. This behavior is deprecated and will be removed in community.general version 9.0.0. - - The parameter O(virtualenv) will remain in use, but it will require the specified virtualenv to exist. - The recommended way to create one in Ansible is by using M(ansible.builtin.pip). + B(ATTENTION): Support for Django releases older than 4.1 has been removed in + community.general version 9.0.0. While the module allows for free-form commands + does not verify the version of Django being used, it is B(strongly recommended) + to use a more recent version of Django. + - Please notice that Django 4.1 requires Python 3.8 or greater. + - This module will not create a virtualenv if the O(virtualenv) parameter is specified and a virtual environment + does not already exist at the given location. This behavior changed in community.general version 9.0.0. + - The recommended way to create a virtual environment in Ansible is by using M(ansible.builtin.pip). - This module assumes English error messages for the V(createcachetable) command to detect table existence, unfortunately. - - To be able to use the V(migrate) command with django versions < 1.7, you must have C(south) installed and added - as an app in your settings. - - To be able to use the V(collectstatic) command, you must have enabled staticfiles in your settings. + - To be able to use the V(collectstatic) command, you must have enabled C(staticfiles) in your settings. - Your C(manage.py) application must be executable (C(rwxr-xr-x)), and must have a valid shebang, for example C(#!/usr/bin/env python), for invoking the appropriate Python interpreter. seealso: @@ -169,7 +156,7 @@ seealso: - name: What Python version can I use with Django? description: From the Django FAQ, the response to Python requirements for the framework. link: https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django -requirements: [ "virtualenv", "django" ] +requirements: [ "django >= 4.1" ] author: - Alexei Znamensky (@russoz) - Scott Anderson (@tastychutney) @@ -178,7 +165,7 @@ author: EXAMPLES = """ - name: Run cleanup on the application installed in django_dir community.general.django_manage: - command: cleanup + command: clearsessions project_path: "{{ django_dir }}" - name: Load the initial_data fixture into the application @@ -189,7 +176,7 @@ EXAMPLES = """ - name: Run syncdb on the application community.general.django_manage: - command: syncdb + command: migrate project_path: "{{ django_dir }}" settings: "{{ settings_app_name }}" pythonpath: "{{ settings_dir }}" @@ -233,22 +220,7 @@ def _ensure_virtualenv(module): activate = os.path.join(vbin, 'activate') if not os.path.exists(activate): - # In version 9.0.0, if the venv is not found, it should fail_json() here. - if not module.params['ack_venv_creation_deprecation']: - module.deprecate( - 'The behavior of "creating the virtual environment when missing" is being ' - 'deprecated and will be removed in community.general version 9.0.0. ' - 'Set the module parameter `ack_venv_creation_deprecation: true` to ' - 'prevent this message from showing up when creating a virtualenv.', - version='9.0.0', - collection_name='community.general', - ) - - virtualenv = module.get_bin_path('virtualenv', True) - vcmd = [virtualenv, venv_param] - rc, out_venv, err_venv = module.run_command(vcmd) - if rc != 0: - _fail(module, vcmd, out_venv, err_venv) + module.fail_json(msg='%s does not point to a valid virtual environment' % venv_param) os.environ["PATH"] = "%s:%s" % (vbin, os.environ["PATH"]) os.environ["VIRTUAL_ENV"] = venv_param @@ -266,11 +238,6 @@ def loaddata_filter_output(line): return "Installed" in line and "Installed 0 object" not in line -def syncdb_filter_output(line): - return ("Creating table " in line) \ - or ("Installed" in line and "Installed 0 object" not in line) - - def migrate_filter_output(line): return ("Migrating forwards " in line) \ or ("Installed" in line and "Installed 0 object" not in line) \ @@ -283,13 +250,10 @@ def collectstatic_filter_output(line): def main(): command_allowed_param_map = dict( - cleanup=(), createcachetable=('cache_table', 'database', ), flush=('database', ), loaddata=('database', 'fixtures', ), - syncdb=('database', ), test=('failfast', 'testrunner', 'apps', ), - validate=(), migrate=('apps', 'skip', 'merge', 'database',), collectstatic=('clear', 'link', ), ) @@ -301,7 +265,6 @@ def main(): # forces --noinput on every command that needs it noinput_commands = ( 'flush', - 'syncdb', 'migrate', 'test', 'collectstatic', @@ -333,7 +296,7 @@ def main(): skip=dict(type='bool'), merge=dict(type='bool'), link=dict(type='bool'), - ack_venv_creation_deprecation=dict(type='bool'), + ack_venv_creation_deprecation=dict(type='bool', removed_in_version='11.0.0', removed_from_collection='community.general'), ), ) @@ -342,21 +305,6 @@ def main(): project_path = module.params['project_path'] virtualenv = module.params['virtualenv'] - try: - _deprecation = dict( - cleanup="clearsessions", - syncdb="migrate", - validate="check", - ) - module.deprecate( - 'The command {0} has been deprecated as it is no longer supported in recent Django versions.' - 'Please use the command {1} instead that provide similar capability.'.format(command_bin, _deprecation[command_bin]), - version='9.0.0', - collection_name='community.general' - ) - except KeyError: - pass - for param in specific_params: value = module.params[param] if value and param not in command_allowed_param_map[command_bin]: diff --git a/plugins/modules/etcd3.py b/plugins/modules/etcd3.py index 2fdc3f2f83..b1bb181cf4 100644 --- a/plugins/modules/etcd3.py +++ b/plugins/modules/etcd3.py @@ -193,13 +193,8 @@ def run_module(): allowed_keys = ['host', 'port', 'ca_cert', 'cert_cert', 'cert_key', 'timeout', 'user', 'password'] - # TODO(evrardjp): Move this back to a dict comprehension when python 2.7 is - # the minimum supported version - # client_params = {key: value for key, value in module.params.items() if key in allowed_keys} - client_params = dict() - for key, value in module.params.items(): - if key in allowed_keys: - client_params[key] = value + + client_params = {key: value for key, value in module.params.items() if key in allowed_keys} try: etcd = etcd3.client(**client_params) except Exception as exp: diff --git a/plugins/modules/filesystem.py b/plugins/modules/filesystem.py index ec361245bd..73e8c79c6a 100644 --- a/plugins/modules/filesystem.py +++ b/plugins/modules/filesystem.py @@ -40,11 +40,12 @@ options: default: present version_added: 1.3.0 fstype: - choices: [ btrfs, ext2, ext3, ext4, ext4dev, f2fs, lvm, ocfs2, reiserfs, xfs, vfat, swap, ufs ] + choices: [ bcachefs, btrfs, ext2, ext3, ext4, ext4dev, f2fs, lvm, ocfs2, reiserfs, xfs, vfat, swap, ufs ] description: - Filesystem type to be created. This option is required with O(state=present) (or if O(state) is omitted). - ufs support has been added in community.general 3.4.0. + - bcachefs support has been added in community.general 8.6.0. type: str aliases: [type] dev: @@ -67,7 +68,7 @@ options: resizefs: description: - If V(true), if the block device and filesystem size differ, grow the filesystem into the space. - - Supported for C(btrfs), C(ext2), C(ext3), C(ext4), C(ext4dev), C(f2fs), C(lvm), C(xfs), C(ufs) and C(vfat) filesystems. + - Supported for C(bcachefs), C(btrfs), C(ext2), C(ext3), C(ext4), C(ext4dev), C(f2fs), C(lvm), C(xfs), C(ufs) and C(vfat) filesystems. Attempts to resize other filesystem types will fail. - XFS Will only grow if mounted. Currently, the module is based on commands from C(util-linux) package to perform operations, so resizing of XFS is @@ -86,7 +87,7 @@ options: - The UUID options specified in O(opts) take precedence over this value. - See xfs_admin(8) (C(xfs)), tune2fs(8) (C(ext2), C(ext3), C(ext4), C(ext4dev)) for possible values. - For O(fstype=lvm) the value is ignored, it resets the PV UUID if set. - - Supported for O(fstype) being one of C(ext2), C(ext3), C(ext4), C(ext4dev), C(lvm), or C(xfs). + - Supported for O(fstype) being one of C(bcachefs), C(ext2), C(ext3), C(ext4), C(ext4dev), C(lvm), or C(xfs). - This is B(not idempotent). Specifying this option will always result in a change. - Mutually exclusive with O(resizefs). type: str @@ -405,6 +406,48 @@ class Reiserfs(Filesystem): MKFS_FORCE_FLAGS = ['-q'] +class Bcachefs(Filesystem): + MKFS = 'mkfs.bcachefs' + MKFS_FORCE_FLAGS = ['--force'] + MKFS_SET_UUID_OPTIONS = ['-U', '--uuid'] + INFO = 'bcachefs' + GROW = 'bcachefs' + GROW_MAX_SPACE_FLAGS = ['device', 'resize'] + + def get_fs_size(self, dev): + """Return size in bytes of filesystem on device (integer).""" + dummy, stdout, dummy = self.module.run_command([self.module.get_bin_path(self.INFO), + 'show-super', str(dev)], check_rc=True) + + for line in stdout.splitlines(): + if "Size: " in line: + parts = line.split() + unit = parts[2] + + base = None + exp = None + + units_2 = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] + units_10 = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] + + try: + exp = units_2.index(unit) + base = 1024 + except ValueError: + exp = units_10.index(unit) + base = 1000 + + if exp == 0: + value = int(parts[1]) + else: + value = float(parts[1]) + + if base is not None and exp is not None: + return int(value * pow(base, exp)) + + raise ValueError(repr(stdout)) + + class Btrfs(Filesystem): MKFS = 'mkfs.btrfs' INFO = 'btrfs' @@ -567,6 +610,7 @@ class UFS(Filesystem): FILESYSTEMS = { + 'bcachefs': Bcachefs, 'ext2': Ext2, 'ext3': Ext3, 'ext4': Ext4, diff --git a/plugins/modules/flatpak.py b/plugins/modules/flatpak.py index 80dbabdfa0..15e404d45b 100644 --- a/plugins/modules/flatpak.py +++ b/plugins/modules/flatpak.py @@ -26,7 +26,9 @@ extends_documentation_fragment: - community.general.attributes attributes: check_mode: - support: full + support: partial + details: + - If O(state=latest), the module will always return C(changed=true). diff_mode: support: none options: @@ -53,12 +55,12 @@ options: - Both C(https://) and C(http://) URLs are supported. - When supplying a reverse DNS name, you can use the O(remote) option to specify on what remote to look for the flatpak. An example for a reverse DNS name is C(org.gnome.gedit). - - When used with O(state=absent), it is recommended to specify the name in the reverse DNS - format. - - When supplying a URL with O(state=absent), the module will try to match the - installed flatpak based on the name of the flatpakref to remove it. However, there is no - guarantee that the names of the flatpakref file and the reverse DNS name of the installed - flatpak do match. + - When used with O(state=absent) or O(state=latest), it is recommended to specify the name in + the reverse DNS format. + - When supplying a URL with O(state=absent) or O(state=latest), the module will try to match the + installed flatpak based on the name of the flatpakref to remove or update it. However, there + is no guarantee that the names of the flatpakref file and the reverse DNS name of the + installed flatpak do match. type: list elements: str required: true @@ -82,7 +84,8 @@ options: state: description: - Indicates the desired package state. - choices: [ absent, present ] + - The value V(latest) is supported since community.general 8.6.0. + choices: [ absent, present, latest ] type: str default: present ''' @@ -118,6 +121,37 @@ EXAMPLES = r''' - org.inkscape.Inkscape - org.mozilla.firefox +- name: Update the spotify flatpak + community.general.flatpak: + name: https://s3.amazonaws.com/alexlarsson/spotify-repo/spotify.flatpakref + state: latest + +- name: Update the gedit flatpak package without dependencies (not recommended) + community.general.flatpak: + name: https://git.gnome.org/browse/gnome-apps-nightly/plain/gedit.flatpakref + state: latest + no_dependencies: true + +- name: Update the gedit package from flathub for current user + community.general.flatpak: + name: org.gnome.gedit + state: latest + method: user + +- name: Update the Gnome Calendar flatpak from the gnome remote system-wide + community.general.flatpak: + name: org.gnome.Calendar + state: latest + remote: gnome + +- name: Update multiple packages + community.general.flatpak: + name: + - org.gimp.GIMP + - org.inkscape.Inkscape + - org.mozilla.firefox + state: latest + - name: Remove the gedit flatpak community.general.flatpak: name: org.gnome.gedit @@ -195,6 +229,28 @@ def install_flat(module, binary, remote, names, method, no_dependencies): result['changed'] = True +def update_flat(module, binary, names, method, no_dependencies): + """Update existing flatpaks.""" + global result # pylint: disable=global-variable-not-assigned + installed_flat_names = [ + _match_installed_flat_name(module, binary, name, method) + for name in names + ] + command = [binary, "update", "--{0}".format(method)] + flatpak_version = _flatpak_version(module, binary) + if LooseVersion(flatpak_version) < LooseVersion('1.1.3'): + command += ["-y"] + else: + command += ["--noninteractive"] + if no_dependencies: + command += ["--no-deps"] + command += installed_flat_names + stdout = _flatpak_command(module, module.check_mode, command) + result["changed"] = ( + True if module.check_mode else stdout.find("Nothing to do.") == -1 + ) + + def uninstall_flat(module, binary, names, method): """Remove existing flatpaks.""" global result # pylint: disable=global-variable-not-assigned @@ -313,7 +369,7 @@ def main(): method=dict(type='str', default='system', choices=['user', 'system']), state=dict(type='str', default='present', - choices=['absent', 'present']), + choices=['absent', 'present', 'latest']), no_dependencies=dict(type='bool', default=False), executable=dict(type='path', default='flatpak') ), @@ -338,10 +394,13 @@ def main(): module.fail_json(msg="Executable '%s' was not found on the system." % executable, **result) installed, not_installed = flatpak_exists(module, binary, name, method) - if state == 'present' and not_installed: - install_flat(module, binary, remote, not_installed, method, no_dependencies) - elif state == 'absent' and installed: + if state == 'absent' and installed: uninstall_flat(module, binary, installed, method) + else: + if state == 'latest' and installed: + update_flat(module, binary, installed, method, no_dependencies) + if state in ('present', 'latest') and not_installed: + install_flat(module, binary, remote, not_installed, method, no_dependencies) module.exit_json(**result) diff --git a/plugins/modules/flowdock.py b/plugins/modules/flowdock.py deleted file mode 100644 index 0e8a7461da..0000000000 --- a/plugins/modules/flowdock.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright 2013 Matt Coddington -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: flowdock -author: "Matt Coddington (@mcodd)" -short_description: Send a message to a flowdock -description: - - Send a message to a flowdock team inbox or chat using the push API (see https://www.flowdock.com/api/team-inbox and https://www.flowdock.com/api/chat) -extends_documentation_fragment: - - community.general.attributes -attributes: - check_mode: - support: full - diff_mode: - support: none -options: - token: - type: str - description: - - API token. - required: true - type: - type: str - description: - - Whether to post to 'inbox' or 'chat' - required: true - choices: [ "inbox", "chat" ] - msg: - type: str - description: - - Content of the message - required: true - tags: - type: str - description: - - tags of the message, separated by commas - required: false - external_user_name: - type: str - description: - - (chat only - required) Name of the "user" sending the message - required: false - from_address: - type: str - description: - - (inbox only - required) Email address of the message sender - required: false - source: - type: str - description: - - (inbox only - required) Human readable identifier of the application that uses the Flowdock API - required: false - subject: - type: str - description: - - (inbox only - required) Subject line of the message - required: false - from_name: - type: str - description: - - (inbox only) Name of the message sender - required: false - reply_to: - type: str - description: - - (inbox only) Email address for replies - required: false - project: - type: str - description: - - (inbox only) Human readable identifier for more detailed message categorization - required: false - link: - type: str - description: - - (inbox only) Link associated with the message. This will be used to link the message subject in Team Inbox. - required: false - validate_certs: - description: - - If V(false), SSL certificates will not be validated. This should only be used - on personally controlled sites using self-signed certificates. - required: false - default: true - type: bool - -requirements: [ ] -''' - -EXAMPLES = ''' -- name: Send a message to a flowdock - community.general.flowdock: - type: inbox - token: AAAAAA - from_address: user@example.com - source: my cool app - msg: test from ansible - subject: test subject - -- name: Send a message to a flowdock - community.general.flowdock: - type: chat - token: AAAAAA - external_user_name: testuser - msg: test from ansible - tags: tag1,tag2,tag3 -''' - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves.urllib.parse import urlencode -from ansible.module_utils.urls import fetch_url - - -# =========================================== -# Module execution. -# - -def main(): - - module = AnsibleModule( - argument_spec=dict( - token=dict(required=True, no_log=True), - msg=dict(required=True), - type=dict(required=True, choices=["inbox", "chat"]), - external_user_name=dict(required=False), - from_address=dict(required=False), - source=dict(required=False), - subject=dict(required=False), - from_name=dict(required=False), - reply_to=dict(required=False), - project=dict(required=False), - tags=dict(required=False), - link=dict(required=False), - validate_certs=dict(default=True, type='bool'), - ), - supports_check_mode=True - ) - - type = module.params["type"] - token = module.params["token"] - if type == 'inbox': - url = "https://api.flowdock.com/v1/messages/team_inbox/%s" % (token) - else: - url = "https://api.flowdock.com/v1/messages/chat/%s" % (token) - - params = {} - - # required params - params['content'] = module.params["msg"] - - # required params for the 'chat' type - if module.params['external_user_name']: - if type == 'inbox': - module.fail_json(msg="external_user_name is not valid for the 'inbox' type") - else: - params['external_user_name'] = module.params["external_user_name"] - elif type == 'chat': - module.fail_json(msg="external_user_name is required for the 'chat' type") - - # required params for the 'inbox' type - for item in ['from_address', 'source', 'subject']: - if module.params[item]: - if type == 'chat': - module.fail_json(msg="%s is not valid for the 'chat' type" % item) - else: - params[item] = module.params[item] - elif type == 'inbox': - module.fail_json(msg="%s is required for the 'inbox' type" % item) - - # optional params - if module.params["tags"]: - params['tags'] = module.params["tags"] - - # optional params for the 'inbox' type - for item in ['from_name', 'reply_to', 'project', 'link']: - if module.params[item]: - if type == 'chat': - module.fail_json(msg="%s is not valid for the 'chat' type" % item) - else: - params[item] = module.params[item] - - # If we're in check mode, just exit pretending like we succeeded - if module.check_mode: - module.exit_json(changed=False) - - # Send the data to Flowdock - data = urlencode(params) - response, info = fetch_url(module, url, data=data) - if info['status'] != 200: - module.fail_json(msg="unable to send msg: %s" % info['msg']) - - module.exit_json(changed=True, msg=module.params["msg"]) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/gandi_livedns.py b/plugins/modules/gandi_livedns.py index fdb7993a5e..ad2e96fd15 100644 --- a/plugins/modules/gandi_livedns.py +++ b/plugins/modules/gandi_livedns.py @@ -25,11 +25,19 @@ attributes: diff_mode: support: none options: + personal_access_token: + description: + - Scoped API token. + - One of O(personal_access_token) and O(api_key) must be specified. + type: str + version_added: 9.0.0 api_key: description: - Account API token. + - Note that these type of keys are deprecated and might stop working at some point. + Use personal access tokens instead. + - One of O(personal_access_token) and O(api_key) must be specified. type: str - required: true record: description: - Record to add. @@ -73,7 +81,7 @@ EXAMPLES = r''' values: - 127.0.0.1 ttl: 7200 - api_key: dummyapitoken + personal_access_token: dummytoken register: record - name: Create a mail CNAME record to www.my.com domain @@ -84,7 +92,7 @@ EXAMPLES = r''' values: - www ttl: 7200 - api_key: dummyapitoken + personal_access_token: dummytoken state: present - name: Change its TTL @@ -95,7 +103,7 @@ EXAMPLES = r''' values: - www ttl: 10800 - api_key: dummyapitoken + personal_access_token: dummytoken state: present - name: Delete the record @@ -103,8 +111,18 @@ EXAMPLES = r''' domain: my.com type: CNAME record: mail - api_key: dummyapitoken + personal_access_token: dummytoken state: absent + +- name: Use a (deprecated) API Key + community.general.gandi_livedns: + domain: my.com + record: test + type: A + values: + - 127.0.0.1 + ttl: 7200 + api_key: dummyapikey ''' RETURN = r''' @@ -151,7 +169,8 @@ from ansible_collections.community.general.plugins.module_utils.gandi_livedns_ap def main(): module = AnsibleModule( argument_spec=dict( - api_key=dict(type='str', required=True, no_log=True), + api_key=dict(type='str', no_log=True), + personal_access_token=dict(type='str', no_log=True), record=dict(type='str', required=True), state=dict(type='str', default='present', choices=['absent', 'present']), ttl=dict(type='int'), @@ -163,6 +182,12 @@ def main(): required_if=[ ('state', 'present', ['values', 'ttl']), ], + mutually_exclusive=[ + ('api_key', 'personal_access_token'), + ], + required_one_of=[ + ('api_key', 'personal_access_token'), + ], ) gandi_api = GandiLiveDNSAPI(module) diff --git a/plugins/modules/gconftool2.py b/plugins/modules/gconftool2.py index a40304a166..deae8a2f16 100644 --- a/plugins/modules/gconftool2.py +++ b/plugins/modules/gconftool2.py @@ -123,12 +123,12 @@ class GConftool(StateModuleHelper): ], supports_check_mode=True, ) + use_old_vardict = False def __init_module__(self): self.runner = gconftool2_runner(self.module, check_rc=True) - if self.vars.state != "get": - if not self.vars.direct and self.vars.config_source is not None: - self.module.fail_json(msg='If the "config_source" is specified then "direct" must be "true"') + if not self.vars.direct and self.vars.config_source is not None: + self.do_raise('If the "config_source" is specified then "direct" must be "true"') self.vars.set('previous_value', self._get(), fact=True) self.vars.set('value_type', self.vars.value_type) @@ -139,7 +139,7 @@ class GConftool(StateModuleHelper): def _make_process(self, fail_on_err): def process(rc, out, err): if err and fail_on_err: - self.ansible.fail_json(msg='gconftool-2 failed with error: %s' % (str(err))) + self.do_raise('gconftool-2 failed with error:\n%s' % err.strip()) out = out.rstrip() self.vars.value = None if out == "" else out return self.vars.value @@ -151,16 +151,14 @@ class GConftool(StateModuleHelper): def state_absent(self): with self.runner("state key", output_process=self._make_process(False)) as ctx: ctx.run() - if self.verbosity >= 4: - self.vars.run_info = ctx.run_info + self.vars.set('run_info', ctx.run_info, verbosity=4) self.vars.set('new_value', None, fact=True) self.vars._value = None def state_present(self): with self.runner("direct config_source value_type state key value", output_process=self._make_process(True)) as ctx: ctx.run() - if self.verbosity >= 4: - self.vars.run_info = ctx.run_info + self.vars.set('run_info', ctx.run_info, verbosity=4) self.vars.set('new_value', self._get(), fact=True) self.vars._value = self.vars.new_value diff --git a/plugins/modules/gconftool2_info.py b/plugins/modules/gconftool2_info.py index 282065b95e..f66e2da8f7 100644 --- a/plugins/modules/gconftool2_info.py +++ b/plugins/modules/gconftool2_info.py @@ -60,6 +60,7 @@ class GConftoolInfo(ModuleHelper): ), supports_check_mode=True, ) + use_old_vardict = False def __init_module__(self): self.runner = gconftool2_runner(self.module, check_rc=True) diff --git a/plugins/modules/gio_mime.py b/plugins/modules/gio_mime.py index 27f90581ef..82c583c76f 100644 --- a/plugins/modules/gio_mime.py +++ b/plugins/modules/gio_mime.py @@ -84,6 +84,7 @@ class GioMime(ModuleHelper): ), supports_check_mode=True, ) + mute_vardict_deprecation = True def __init_module__(self): self.runner = gio_mime_runner(self.module, check_rc=True) diff --git a/plugins/modules/git_config.py b/plugins/modules/git_config.py index a8d2ebe979..95969c1b38 100644 --- a/plugins/modules/git_config.py +++ b/plugins/modules/git_config.py @@ -18,7 +18,7 @@ author: - Matthew Gamble (@djmattyg007) - Marius Gedminas (@mgedmin) requirements: ['git'] -short_description: Read and write git configuration +short_description: Update git configuration description: - The M(community.general.git_config) module changes git configuration by invoking C(git config). This is needed if you do not want to use M(ansible.builtin.template) for the entire git @@ -36,6 +36,8 @@ options: list_all: description: - List all settings (optionally limited to a given O(scope)). + - This option is B(deprecated) and will be removed from community.general 11.0.0. + Please use M(community.general.git_config_info) instead. type: bool default: false name: @@ -74,6 +76,8 @@ options: description: - When specifying the name of a single setting, supply a value to set that setting to the given value. + - From community.general 11.0.0 on, O(value) will be required if O(state=present). + To read values, use the M(community.general.git_config_info) module instead. type: str add_mode: description: @@ -143,29 +147,6 @@ EXAMPLES = ''' repo: /etc scope: local value: 'root@{{ ansible_fqdn }}' - -- name: Read individual values from git config - community.general.git_config: - name: alias.ci - scope: global - -- name: Scope system is also assumed when reading values, unless list_all=true - community.general.git_config: - name: alias.diffc - -- name: Read all values from git config - community.general.git_config: - list_all: true - scope: global - -- name: When list_all is yes and no scope is specified, you get configuration from all scopes - community.general.git_config: - list_all: true - -- name: Specify a repository to include local settings - community.general.git_config: - list_all: true - repo: /path/to/repo.git ''' RETURN = ''' @@ -193,7 +174,7 @@ from ansible.module_utils.basic import AnsibleModule def main(): module = AnsibleModule( argument_spec=dict( - list_all=dict(required=False, type='bool', default=False), + list_all=dict(required=False, type='bool', default=False, removed_in_version='11.0.0', removed_from_collection='community.general'), name=dict(type='str'), repo=dict(type='path'), file=dict(type='path'), @@ -222,6 +203,14 @@ def main(): new_value = params['value'] or '' add_mode = params['add_mode'] + if not unset and not new_value and not params['list_all']: + module.deprecate( + 'If state=present, a value must be specified from community.general 11.0.0 on.' + ' To read a config value, use the community.general.git_config_info module instead.', + version='11.0.0', + collection_name='community.general', + ) + scope = determine_scope(params) cwd = determine_cwd(scope, params) @@ -263,7 +252,7 @@ def main(): module.exit_json(changed=False, msg='', config_value=old_values[0] if old_values else '') elif unset and not out: module.exit_json(changed=False, msg='no setting to unset') - elif new_value in old_values and (len(old_values) == 1 or add_mode == "add"): + elif new_value in old_values and (len(old_values) == 1 or add_mode == "add") and not unset: module.exit_json(changed=False, msg="") # Until this point, the git config was just read and in case no change is needed, the module has already exited. diff --git a/plugins/modules/github_key.py b/plugins/modules/github_key.py index fa3a0a01fa..a74ead9848 100644 --- a/plugins/modules/github_key.py +++ b/plugins/modules/github_key.py @@ -91,12 +91,17 @@ EXAMPLES = ''' pubkey: "{{ lookup('ansible.builtin.file', '/home/foo/.ssh/id_rsa.pub') }}" ''' +import datetime import json import re from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import fetch_url +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + API_BASE = 'https://api.github.com' @@ -151,14 +156,13 @@ def get_all_keys(session): def create_key(session, name, pubkey, check_mode): if check_mode: - from datetime import datetime - now = datetime.utcnow() + now_t = now() return { 'id': 0, 'key': pubkey, 'title': name, 'url': 'http://example.com/CHECK_MODE_GITHUB_KEY', - 'created_at': datetime.strftime(now, '%Y-%m-%dT%H:%M:%SZ'), + 'created_at': datetime.strftime(now_t, '%Y-%m-%dT%H:%M:%SZ'), 'read_only': False, 'verified': False } diff --git a/plugins/modules/gitlab_group.py b/plugins/modules/gitlab_group.py index 3d57b18528..1f4dadff70 100644 --- a/plugins/modules/gitlab_group.py +++ b/plugins/modules/gitlab_group.py @@ -261,7 +261,7 @@ class GitLabGroup(object): try: # Filter out None values - filtered = dict((arg_key, arg_value) for arg_key, arg_value in arguments.items() if arg_value is not None) + filtered = {arg_key: arg_value for arg_key, arg_value in arguments.items() if arg_value is not None} group = self._gitlab.groups.create(filtered) except (gitlab.exceptions.GitlabCreateError) as e: diff --git a/plugins/modules/gitlab_group_access_token.py b/plugins/modules/gitlab_group_access_token.py index 85bba205db..1db7414081 100644 --- a/plugins/modules/gitlab_group_access_token.py +++ b/plugins/modules/gitlab_group_access_token.py @@ -313,7 +313,10 @@ def main(): module.exit_json(changed=True, msg="Successfully recreated access token", access_token=gitlab_access_token.access_token_object._attrs) else: gitlab_access_token.create_access_token(group, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) - module.exit_json(changed=True, msg="Successfully created access token", access_token=gitlab_access_token.access_token_object._attrs) + if module.check_mode: + module.exit_json(changed=True, msg="Successfully created access token", access_token={}) + else: + module.exit_json(changed=True, msg="Successfully created access token", access_token=gitlab_access_token.access_token_object._attrs) if __name__ == '__main__': diff --git a/plugins/modules/gitlab_project.py b/plugins/modules/gitlab_project.py index f1b96bfac5..a85f2bd827 100644 --- a/plugins/modules/gitlab_project.py +++ b/plugins/modules/gitlab_project.py @@ -34,152 +34,17 @@ attributes: support: none options: - group: - description: - - Id or the full path of the group of which this projects belongs to. - type: str - name: - description: - - The name of the project. - required: true - type: str - path: - description: - - The path of the project you want to create, this will be server_url//path. - - If not supplied, name will be used. - type: str - description: - description: - - An description for the project. - type: str - initialize_with_readme: - description: - - Will initialize the project with a default C(README.md). - - Is only used when the project is created, and ignored otherwise. - type: bool - default: false - version_added: "4.0.0" - issues_enabled: - description: - - Whether you want to create issues or not. - - Possible values are true and false. - type: bool - default: true - merge_requests_enabled: - description: - - If merge requests can be made or not. - - Possible values are true and false. - type: bool - default: true - wiki_enabled: - description: - - If an wiki for this project should be available or not. - type: bool - default: true - snippets_enabled: - description: - - If creating snippets should be available or not. - type: bool - default: true - visibility: - description: - - V(private) Project access must be granted explicitly for each user. - - V(internal) The project can be cloned by any logged in user. - - V(public) The project can be cloned without any authentication. - default: private - type: str - choices: ["private", "internal", "public"] - aliases: - - visibility_level - import_url: - description: - - Git repository which will be imported into gitlab. - - GitLab server needs read access to this git repository. - required: false - type: str - state: - description: - - Create or delete project. - - Possible values are present and absent. - default: present - type: str - choices: ["present", "absent"] - merge_method: - description: - - What requirements are placed upon merges. - - Possible values are V(merge), V(rebase_merge) merge commit with semi-linear history, V(ff) fast-forward merges only. - type: str - choices: ["ff", "merge", "rebase_merge"] - default: merge - version_added: "1.0.0" - lfs_enabled: - description: - - Enable Git large file systems to manages large files such - as audio, video, and graphics files. - type: bool - required: false - default: false - version_added: "2.0.0" - username: - description: - - Used to create a personal project under a user's name. - type: str - version_added: "3.3.0" allow_merge_on_skipped_pipeline: description: - Allow merge when skipped pipelines exist. type: bool version_added: "3.4.0" - only_allow_merge_if_all_discussions_are_resolved: - description: - - All discussions on a merge request (MR) have to be resolved. - type: bool - version_added: "3.4.0" - only_allow_merge_if_pipeline_succeeds: - description: - - Only allow merges if pipeline succeeded. - type: bool - version_added: "3.4.0" - packages_enabled: - description: - - Enable GitLab package repository. - type: bool - version_added: "3.4.0" - remove_source_branch_after_merge: - description: - - Remove the source branch after merge. - type: bool - version_added: "3.4.0" - squash_option: - description: - - Squash commits when merging. - type: str - choices: ["never", "always", "default_off", "default_on"] - version_added: "3.4.0" - ci_config_path: - description: - - Custom path to the CI configuration file for this project. - type: str - version_added: "3.7.0" - shared_runners_enabled: - description: - - Enable shared runners for this project. - type: bool - version_added: "3.7.0" avatar_path: description: - Absolute path image to configure avatar. File size should not exceed 200 kb. - This option is only used on creation, not for updates. type: path version_added: "4.2.0" - default_branch: - description: - - The default branch name for this project. - - For project creation, this option requires O(initialize_with_readme=true). - - For project update, the branch must exist. - - Supports project's default branch update since community.general 8.0.0. - type: str - version_added: "4.2.0" builds_access_level: description: - V(private) means that repository CI/CD is allowed only to project members. @@ -188,14 +53,46 @@ options: type: str choices: ["private", "disabled", "enabled"] version_added: "6.2.0" - forking_access_level: + ci_config_path: description: - - V(private) means that repository forks is allowed only to project members. - - V(disabled) means that repository forks are disabled. - - V(enabled) means that repository forks are enabled. + - Custom path to the CI configuration file for this project. type: str - choices: ["private", "disabled", "enabled"] - version_added: "6.2.0" + version_added: "3.7.0" + container_expiration_policy: + description: + - Project cleanup policy for its container registry. + type: dict + suboptions: + cadence: + description: + - How often cleanup should be run. + type: str + choices: ["1d", "7d", "14d", "1month", "3month"] + enabled: + description: + - Enable the cleanup policy. + type: bool + keep_n: + description: + - Number of tags kept per image name. + - V(0) clears the field. + type: int + choices: [0, 1, 5, 10, 25, 50, 100] + older_than: + description: + - Destroy tags older than this. + - V(0d) clears the field. + type: str + choices: ["0d", "7d", "14d", "30d", "90d"] + name_regex: + description: + - Destroy tags matching this regular expression. + type: str + name_regex_keep: + description: + - Keep tags matching this regular expression. + type: str + version_added: "9.3.0" container_registry_access_level: description: - V(private) means that container registry is allowed only to project members. @@ -204,14 +101,18 @@ options: type: str choices: ["private", "disabled", "enabled"] version_added: "6.2.0" - releases_access_level: + default_branch: description: - - V(private) means that accessing release is allowed only to project members. - - V(disabled) means that accessing release is disabled. - - V(enabled) means that accessing release is enabled. + - The default branch name for this project. + - For project creation, this option requires O(initialize_with_readme=true). + - For project update, the branch must exist. + - Supports project's default branch update since community.general 8.0.0. + type: str + version_added: "4.2.0" + description: + description: + - An description for the project. type: str - choices: ["private", "disabled", "enabled"] - version_added: "6.4.0" environments_access_level: description: - V(private) means that deployment to environment is allowed only to project members. @@ -228,6 +129,24 @@ options: type: str choices: ["private", "disabled", "enabled"] version_added: "6.4.0" + forking_access_level: + description: + - V(private) means that repository forks is allowed only to project members. + - V(disabled) means that repository forks are disabled. + - V(enabled) means that repository forks are enabled. + type: str + choices: ["private", "disabled", "enabled"] + version_added: "6.2.0" + group: + description: + - Id or the full path of the group of which this projects belongs to. + type: str + import_url: + description: + - Git repository which will be imported into gitlab. + - GitLab server needs read access to this git repository. + required: false + type: str infrastructure_access_level: description: - V(private) means that configuring infrastructure is allowed only to project members. @@ -236,6 +155,57 @@ options: type: str choices: ["private", "disabled", "enabled"] version_added: "6.4.0" + initialize_with_readme: + description: + - Will initialize the project with a default C(README.md). + - Is only used when the project is created, and ignored otherwise. + type: bool + default: false + version_added: "4.0.0" + issues_access_level: + description: + - V(private) means that accessing issues tab is allowed only to project members. + - V(disabled) means that accessing issues tab is disabled. + - V(enabled) means that accessing issues tab is enabled. + - O(issues_access_level) and O(issues_enabled) are mutually exclusive. + type: str + choices: ["private", "disabled", "enabled"] + version_added: "9.4.0" + issues_enabled: + description: + - Whether you want to create issues or not. + - O(issues_access_level) and O(issues_enabled) are mutually exclusive. + type: bool + default: true + lfs_enabled: + description: + - Enable Git large file systems to manages large files such + as audio, video, and graphics files. + type: bool + required: false + default: false + version_added: "2.0.0" + merge_method: + description: + - What requirements are placed upon merges. + - Possible values are V(merge), V(rebase_merge) merge commit with semi-linear history, V(ff) fast-forward merges only. + type: str + choices: ["ff", "merge", "rebase_merge"] + default: merge + version_added: "1.0.0" + merge_requests_enabled: + description: + - If merge requests can be made or not. + type: bool + default: true + model_registry_access_level: + description: + - V(private) means that accessing model registry tab is allowed only to project members. + - V(disabled) means that accessing model registry tab is disabled. + - V(enabled) means that accessing model registry tab is enabled. + type: str + choices: ["private", "disabled", "enabled"] + version_added: "9.3.0" monitor_access_level: description: - V(private) means that monitoring health is allowed only to project members. @@ -244,6 +214,60 @@ options: type: str choices: ["private", "disabled", "enabled"] version_added: "6.4.0" + name: + description: + - The name of the project. + required: true + type: str + only_allow_merge_if_all_discussions_are_resolved: + description: + - All discussions on a merge request (MR) have to be resolved. + type: bool + version_added: "3.4.0" + only_allow_merge_if_pipeline_succeeds: + description: + - Only allow merges if pipeline succeeded. + type: bool + version_added: "3.4.0" + packages_enabled: + description: + - Enable GitLab package repository. + type: bool + version_added: "3.4.0" + pages_access_level: + description: + - V(private) means that accessing pages tab is allowed only to project members. + - V(disabled) means that accessing pages tab is disabled. + - V(enabled) means that accessing pages tab is enabled. + type: str + choices: ["private", "disabled", "enabled"] + version_added: "9.3.0" + path: + description: + - The path of the project you want to create, this will be server_url//path. + - If not supplied, name will be used. + type: str + releases_access_level: + description: + - V(private) means that accessing release is allowed only to project members. + - V(disabled) means that accessing release is disabled. + - V(enabled) means that accessing release is enabled. + type: str + choices: ["private", "disabled", "enabled"] + version_added: "6.4.0" + remove_source_branch_after_merge: + description: + - Remove the source branch after merge. + type: bool + version_added: "3.4.0" + repository_access_level: + description: + - V(private) means that accessing repository is allowed only to project members. + - V(disabled) means that accessing repository is disabled. + - V(enabled) means that accessing repository is enabled. + type: str + choices: ["private", "disabled", "enabled"] + version_added: "9.3.0" security_and_compliance_access_level: description: - V(private) means that accessing security and complicance tab is allowed only to project members. @@ -252,6 +276,34 @@ options: type: str choices: ["private", "disabled", "enabled"] version_added: "6.4.0" + service_desk_enabled: + description: + - Enable Service Desk. + type: bool + version_added: "9.3.0" + shared_runners_enabled: + description: + - Enable shared runners for this project. + type: bool + version_added: "3.7.0" + snippets_enabled: + description: + - If creating snippets should be available or not. + type: bool + default: true + squash_option: + description: + - Squash commits when merging. + type: str + choices: ["never", "always", "default_off", "default_on"] + version_added: "3.4.0" + state: + description: + - Create or delete project. + - Possible values are present and absent. + default: present + type: str + choices: ["present", "absent"] topics: description: - A topic or list of topics to be assigned to a project. @@ -259,6 +311,26 @@ options: type: list elements: str version_added: "6.6.0" + username: + description: + - Used to create a personal project under a user's name. + type: str + version_added: "3.3.0" + visibility: + description: + - V(private) Project access must be granted explicitly for each user. + - V(internal) The project can be cloned by any logged in user. + - V(public) The project can be cloned without any authentication. + default: private + type: str + choices: ["private", "internal", "public"] + aliases: + - visibility_level + wiki_enabled: + description: + - If an wiki for this project should be available or not. + type: bool + default: true ''' EXAMPLES = r''' @@ -358,32 +430,38 @@ class GitLabProject(object): def create_or_update_project(self, module, project_name, namespace, options): changed = False project_options = { - 'name': project_name, - 'description': options['description'], - 'issues_enabled': options['issues_enabled'], - 'merge_requests_enabled': options['merge_requests_enabled'], - 'merge_method': options['merge_method'], - 'wiki_enabled': options['wiki_enabled'], - 'snippets_enabled': options['snippets_enabled'], - 'visibility': options['visibility'], - 'lfs_enabled': options['lfs_enabled'], 'allow_merge_on_skipped_pipeline': options['allow_merge_on_skipped_pipeline'], + 'builds_access_level': options['builds_access_level'], + 'ci_config_path': options['ci_config_path'], + 'container_expiration_policy': options['container_expiration_policy'], + 'container_registry_access_level': options['container_registry_access_level'], + 'description': options['description'], + 'environments_access_level': options['environments_access_level'], + 'feature_flags_access_level': options['feature_flags_access_level'], + 'forking_access_level': options['forking_access_level'], + 'infrastructure_access_level': options['infrastructure_access_level'], + 'issues_access_level': options['issues_access_level'], + 'issues_enabled': options['issues_enabled'], + 'lfs_enabled': options['lfs_enabled'], + 'merge_method': options['merge_method'], + 'merge_requests_enabled': options['merge_requests_enabled'], + 'model_registry_access_level': options['model_registry_access_level'], + 'monitor_access_level': options['monitor_access_level'], + 'name': project_name, 'only_allow_merge_if_all_discussions_are_resolved': options['only_allow_merge_if_all_discussions_are_resolved'], 'only_allow_merge_if_pipeline_succeeds': options['only_allow_merge_if_pipeline_succeeds'], 'packages_enabled': options['packages_enabled'], - 'remove_source_branch_after_merge': options['remove_source_branch_after_merge'], - 'squash_option': options['squash_option'], - 'ci_config_path': options['ci_config_path'], - 'shared_runners_enabled': options['shared_runners_enabled'], - 'builds_access_level': options['builds_access_level'], - 'forking_access_level': options['forking_access_level'], - 'container_registry_access_level': options['container_registry_access_level'], + 'pages_access_level': options['pages_access_level'], 'releases_access_level': options['releases_access_level'], - 'environments_access_level': options['environments_access_level'], - 'feature_flags_access_level': options['feature_flags_access_level'], - 'infrastructure_access_level': options['infrastructure_access_level'], - 'monitor_access_level': options['monitor_access_level'], + 'remove_source_branch_after_merge': options['remove_source_branch_after_merge'], + 'repository_access_level': options['repository_access_level'], 'security_and_compliance_access_level': options['security_and_compliance_access_level'], + 'service_desk_enabled': options['service_desk_enabled'], + 'shared_runners_enabled': options['shared_runners_enabled'], + 'snippets_enabled': options['snippets_enabled'], + 'squash_option': options['squash_option'], + 'visibility': options['visibility'], + 'wiki_enabled': options['wiki_enabled'], } # topics was introduced on gitlab >=14 and replace tag_list. We get current gitlab version @@ -396,7 +474,7 @@ class GitLabProject(object): # Because we have already call userExists in main() if self.project_object is None: if options['default_branch'] and not options['initialize_with_readme']: - module.fail_json(msg="Param default_branch need param initialize_with_readme set to true") + module.fail_json(msg="Param default_branch needs param initialize_with_readme set to true") project_options.update({ 'path': options['path'], 'import_url': options['import_url'], @@ -430,7 +508,7 @@ class GitLabProject(object): try: project.save() except Exception as e: - self._module.fail_json(msg="Failed update project: %s " % e) + self._module.fail_json(msg="Failed to update project: %s " % e) return True return False @@ -443,6 +521,8 @@ class GitLabProject(object): return True arguments['namespace_id'] = namespace.id + if 'container_expiration_policy' in arguments: + arguments['container_expiration_policy_attributes'] = arguments['container_expiration_policy'] try: project = self._gitlab.projects.create(arguments) except (gitlab.exceptions.GitlabCreateError) as e: @@ -454,11 +534,7 @@ class GitLabProject(object): @param arguments Attributes of the project ''' def get_options_with_value(self, arguments): - ret_arguments = dict() - for arg_key, arg_value in arguments.items(): - if arguments[arg_key] is not None: - ret_arguments[arg_key] = arg_value - + ret_arguments = {k: v for k, v in arguments.items() if v is not None} return ret_arguments ''' @@ -469,9 +545,22 @@ class GitLabProject(object): changed = False for arg_key, arg_value in arguments.items(): - if arguments[arg_key] is not None: - if getattr(project, arg_key) != arguments[arg_key]: - setattr(project, arg_key, arguments[arg_key]) + if arg_value is not None: + if getattr(project, arg_key, None) != arg_value: + if arg_key == 'container_expiration_policy': + old_val = getattr(project, arg_key, {}) + final_val = {key: value for key, value in arg_value.items() if value is not None} + + if final_val.get('older_than') == '0d': + final_val['older_than'] = None + if final_val.get('keep_n') == 0: + final_val['keep_n'] = None + + if all(old_val.get(key) == value for key, value in final_val.items()): + continue + setattr(project, 'container_expiration_policy_attributes', final_val) + else: + setattr(project, arg_key, arg_value) changed = True return (changed, project) @@ -501,41 +590,54 @@ def main(): argument_spec = basic_auth_argument_spec() argument_spec.update(auth_argument_spec()) argument_spec.update(dict( - group=dict(type='str'), - name=dict(type='str', required=True), - path=dict(type='str'), - description=dict(type='str'), - initialize_with_readme=dict(type='bool', default=False), - default_branch=dict(type='str'), - issues_enabled=dict(type='bool', default=True), - merge_requests_enabled=dict(type='bool', default=True), - merge_method=dict(type='str', default='merge', choices=["merge", "rebase_merge", "ff"]), - wiki_enabled=dict(type='bool', default=True), - snippets_enabled=dict(default=True, type='bool'), - visibility=dict(type='str', default="private", choices=["internal", "private", "public"], aliases=["visibility_level"]), - import_url=dict(type='str'), - state=dict(type='str', default="present", choices=["absent", "present"]), - lfs_enabled=dict(default=False, type='bool'), - username=dict(type='str'), allow_merge_on_skipped_pipeline=dict(type='bool'), + avatar_path=dict(type='path'), + builds_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + ci_config_path=dict(type='str'), + container_expiration_policy=dict(type='dict', default=None, options=dict( + cadence=dict(type='str', choices=["1d", "7d", "14d", "1month", "3month"]), + enabled=dict(type='bool'), + keep_n=dict(type='int', choices=[0, 1, 5, 10, 25, 50, 100]), + older_than=dict(type='str', choices=["0d", "7d", "14d", "30d", "90d"]), + name_regex=dict(type='str'), + name_regex_keep=dict(type='str'), + )), + container_registry_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + default_branch=dict(type='str'), + description=dict(type='str'), + environments_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + feature_flags_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + forking_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + group=dict(type='str'), + import_url=dict(type='str'), + infrastructure_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + initialize_with_readme=dict(type='bool', default=False), + issues_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + issues_enabled=dict(type='bool', default=True), + lfs_enabled=dict(default=False, type='bool'), + merge_method=dict(type='str', default='merge', choices=["merge", "rebase_merge", "ff"]), + merge_requests_enabled=dict(type='bool', default=True), + model_registry_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + monitor_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + name=dict(type='str', required=True), only_allow_merge_if_all_discussions_are_resolved=dict(type='bool'), only_allow_merge_if_pipeline_succeeds=dict(type='bool'), packages_enabled=dict(type='bool'), - remove_source_branch_after_merge=dict(type='bool'), - squash_option=dict(type='str', choices=['never', 'always', 'default_off', 'default_on']), - ci_config_path=dict(type='str'), - shared_runners_enabled=dict(type='bool'), - avatar_path=dict(type='path'), - builds_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), - forking_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), - container_registry_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + pages_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + path=dict(type='str'), releases_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), - environments_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), - feature_flags_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), - infrastructure_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), - monitor_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + remove_source_branch_after_merge=dict(type='bool'), + repository_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), security_and_compliance_access_level=dict(type='str', choices=['private', 'disabled', 'enabled']), + service_desk_enabled=dict(type='bool'), + shared_runners_enabled=dict(type='bool'), + snippets_enabled=dict(default=True, type='bool'), + squash_option=dict(type='str', choices=['never', 'always', 'default_off', 'default_on']), + state=dict(type='str', default="present", choices=["absent", "present"]), topics=dict(type='list', elements='str'), + username=dict(type='str'), + visibility=dict(type='str', default="private", choices=["internal", "private", "public"], aliases=["visibility_level"]), + wiki_enabled=dict(type='bool', default=True), )) module = AnsibleModule( @@ -547,6 +649,7 @@ def main(): ['api_token', 'api_oauth_token'], ['api_token', 'api_job_token'], ['group', 'username'], + ['issues_access_level', 'issues_enabled'], ], required_together=[ ['api_username', 'api_password'], @@ -560,41 +663,47 @@ def main(): # check prerequisites and connect to gitlab server gitlab_instance = gitlab_authentication(module) - group_identifier = module.params['group'] - project_name = module.params['name'] - project_path = module.params['path'] - project_description = module.params['description'] - initialize_with_readme = module.params['initialize_with_readme'] - issues_enabled = module.params['issues_enabled'] - merge_requests_enabled = module.params['merge_requests_enabled'] - merge_method = module.params['merge_method'] - wiki_enabled = module.params['wiki_enabled'] - snippets_enabled = module.params['snippets_enabled'] - visibility = module.params['visibility'] - import_url = module.params['import_url'] - state = module.params['state'] - lfs_enabled = module.params['lfs_enabled'] - username = module.params['username'] allow_merge_on_skipped_pipeline = module.params['allow_merge_on_skipped_pipeline'] + avatar_path = module.params['avatar_path'] + builds_access_level = module.params['builds_access_level'] + ci_config_path = module.params['ci_config_path'] + container_expiration_policy = module.params['container_expiration_policy'] + container_registry_access_level = module.params['container_registry_access_level'] + default_branch = module.params['default_branch'] + environments_access_level = module.params['environments_access_level'] + feature_flags_access_level = module.params['feature_flags_access_level'] + forking_access_level = module.params['forking_access_level'] + group_identifier = module.params['group'] + import_url = module.params['import_url'] + infrastructure_access_level = module.params['infrastructure_access_level'] + initialize_with_readme = module.params['initialize_with_readme'] + issues_access_level = module.params['issues_access_level'] + issues_enabled = module.params['issues_enabled'] + lfs_enabled = module.params['lfs_enabled'] + merge_method = module.params['merge_method'] + merge_requests_enabled = module.params['merge_requests_enabled'] + model_registry_access_level = module.params['model_registry_access_level'] + monitor_access_level = module.params['monitor_access_level'] only_allow_merge_if_all_discussions_are_resolved = module.params['only_allow_merge_if_all_discussions_are_resolved'] only_allow_merge_if_pipeline_succeeds = module.params['only_allow_merge_if_pipeline_succeeds'] packages_enabled = module.params['packages_enabled'] - remove_source_branch_after_merge = module.params['remove_source_branch_after_merge'] - squash_option = module.params['squash_option'] - ci_config_path = module.params['ci_config_path'] - shared_runners_enabled = module.params['shared_runners_enabled'] - avatar_path = module.params['avatar_path'] - default_branch = module.params['default_branch'] - builds_access_level = module.params['builds_access_level'] - forking_access_level = module.params['forking_access_level'] - container_registry_access_level = module.params['container_registry_access_level'] + pages_access_level = module.params['pages_access_level'] + project_description = module.params['description'] + project_name = module.params['name'] + project_path = module.params['path'] releases_access_level = module.params['releases_access_level'] - environments_access_level = module.params['environments_access_level'] - feature_flags_access_level = module.params['feature_flags_access_level'] - infrastructure_access_level = module.params['infrastructure_access_level'] - monitor_access_level = module.params['monitor_access_level'] + remove_source_branch_after_merge = module.params['remove_source_branch_after_merge'] + repository_access_level = module.params['repository_access_level'] security_and_compliance_access_level = module.params['security_and_compliance_access_level'] + service_desk_enabled = module.params['service_desk_enabled'] + shared_runners_enabled = module.params['shared_runners_enabled'] + snippets_enabled = module.params['snippets_enabled'] + squash_option = module.params['squash_option'] + state = module.params['state'] topics = module.params['topics'] + username = module.params['username'] + visibility = module.params['visibility'] + wiki_enabled = module.params['wiki_enabled'] # Set project_path to project_name if it is empty. if project_path is None: @@ -633,42 +742,48 @@ def main(): if project_exists: gitlab_project.delete_project() module.exit_json(changed=True, msg="Successfully deleted project %s" % project_name) - module.exit_json(changed=False, msg="Project deleted or does not exists") + module.exit_json(changed=False, msg="Project deleted or does not exist") if state == 'present': if gitlab_project.create_or_update_project(module, project_name, namespace, { - "path": project_path, - "description": project_description, - "initialize_with_readme": initialize_with_readme, - "default_branch": default_branch, - "issues_enabled": issues_enabled, - "merge_requests_enabled": merge_requests_enabled, - "merge_method": merge_method, - "wiki_enabled": wiki_enabled, - "snippets_enabled": snippets_enabled, - "visibility": visibility, - "import_url": import_url, - "lfs_enabled": lfs_enabled, "allow_merge_on_skipped_pipeline": allow_merge_on_skipped_pipeline, + "avatar_path": avatar_path, + "builds_access_level": builds_access_level, + "ci_config_path": ci_config_path, + "container_expiration_policy": container_expiration_policy, + "container_registry_access_level": container_registry_access_level, + "default_branch": default_branch, + "description": project_description, + "environments_access_level": environments_access_level, + "feature_flags_access_level": feature_flags_access_level, + "forking_access_level": forking_access_level, + "import_url": import_url, + "infrastructure_access_level": infrastructure_access_level, + "initialize_with_readme": initialize_with_readme, + "issues_access_level": issues_access_level, + "issues_enabled": issues_enabled, + "lfs_enabled": lfs_enabled, + "merge_method": merge_method, + "merge_requests_enabled": merge_requests_enabled, + "model_registry_access_level": model_registry_access_level, + "monitor_access_level": monitor_access_level, "only_allow_merge_if_all_discussions_are_resolved": only_allow_merge_if_all_discussions_are_resolved, "only_allow_merge_if_pipeline_succeeds": only_allow_merge_if_pipeline_succeeds, "packages_enabled": packages_enabled, - "remove_source_branch_after_merge": remove_source_branch_after_merge, - "squash_option": squash_option, - "ci_config_path": ci_config_path, - "shared_runners_enabled": shared_runners_enabled, - "avatar_path": avatar_path, - "builds_access_level": builds_access_level, - "forking_access_level": forking_access_level, - "container_registry_access_level": container_registry_access_level, + "pages_access_level": pages_access_level, + "path": project_path, "releases_access_level": releases_access_level, - "environments_access_level": environments_access_level, - "feature_flags_access_level": feature_flags_access_level, - "infrastructure_access_level": infrastructure_access_level, - "monitor_access_level": monitor_access_level, + "remove_source_branch_after_merge": remove_source_branch_after_merge, + "repository_access_level": repository_access_level, "security_and_compliance_access_level": security_and_compliance_access_level, + "service_desk_enabled": service_desk_enabled, + "shared_runners_enabled": shared_runners_enabled, + "snippets_enabled": snippets_enabled, + "squash_option": squash_option, "topics": topics, + "visibility": visibility, + "wiki_enabled": wiki_enabled, }): module.exit_json(changed=True, msg="Successfully created or updated the project %s" % project_name, project=gitlab_project.project_object._attrs) diff --git a/plugins/modules/gitlab_project_access_token.py b/plugins/modules/gitlab_project_access_token.py index e692a30577..9bfbc51cc7 100644 --- a/plugins/modules/gitlab_project_access_token.py +++ b/plugins/modules/gitlab_project_access_token.py @@ -311,7 +311,10 @@ def main(): module.exit_json(changed=True, msg="Successfully recreated access token", access_token=gitlab_access_token.access_token_object._attrs) else: gitlab_access_token.create_access_token(project, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) - module.exit_json(changed=True, msg="Successfully created access token", access_token=gitlab_access_token.access_token_object._attrs) + if module.check_mode: + module.exit_json(changed=True, msg="Successfully created access token", access_token={}) + else: + module.exit_json(changed=True, msg="Successfully created access token", access_token=gitlab_access_token.access_token_object._attrs) if __name__ == '__main__': diff --git a/plugins/modules/gitlab_runner.py b/plugins/modules/gitlab_runner.py index e6163a6b6c..b11e029103 100644 --- a/plugins/modules/gitlab_runner.py +++ b/plugins/modules/gitlab_runner.py @@ -15,17 +15,20 @@ DOCUMENTATION = ''' module: gitlab_runner short_description: Create, modify and delete GitLab Runners description: - - Register, update and delete runners with the GitLab API. + - Register, update and delete runners on GitLab Server side with the GitLab API. - All operations are performed using the GitLab API v4. - - For details, consult the full API documentation at U(https://docs.gitlab.com/ee/api/runners.html). + - For details, consult the full API documentation at U(https://docs.gitlab.com/ee/api/runners.html) + and U(https://docs.gitlab.com/ee/api/users.html#create-a-runner-linked-to-a-user). - A valid private API token is required for all operations. You can create as many tokens as you like using the GitLab web interface at U(https://$GITLAB_URL/profile/personal_access_tokens). - A valid registration token is required for registering a new runner. To create shared runners, you need to ask your administrator to give you this token. It can be found at U(https://$GITLAB_URL/admin/runners/). + - This module does not handle the C(gitlab-runner) process part, but only manages the runner on GitLab Server side through its API. + Once the module has created the runner, you may use the generated token to run C(gitlab-runner register) command notes: - To create a new runner at least the O(api_token), O(description) and O(api_url) options are required. - - Runners need to have unique descriptions. + - Runners need to have unique descriptions, since this attribute is used as key for idempotency author: - Samy Coenen (@SamyCoenen) - Guillaume Martinez (@Lunik) @@ -153,7 +156,45 @@ options: ''' EXAMPLES = ''' -- name: "Register runner" +- name: Create an instance-level runner + community.general.gitlab_runner: + api_url: https://gitlab.example.com/ + api_token: "{{ access_token }}" + description: Docker Machine t1 + state: present + active: true + tag_list: ['docker'] + run_untagged: false + locked: false + register: runner # Register module output to run C(gitlab-runner register) command in another task + +- name: Create a group-level runner + community.general.gitlab_runner: + api_url: https://gitlab.example.com/ + api_token: "{{ access_token }}" + description: Docker Machine t1 + state: present + active: true + tag_list: ['docker'] + run_untagged: false + locked: false + group: top-level-group/subgroup + register: runner # Register module output to run C(gitlab-runner register) command in another task + +- name: Create a project-level runner + community.general.gitlab_runner: + api_url: https://gitlab.example.com/ + api_token: "{{ access_token }}" + description: Docker Machine t1 + state: present + active: true + tag_list: ['docker'] + run_untagged: false + locked: false + project: top-level-group/subgroup/project + register: runner # Register module output to run C(gitlab-runner register) command in another task + +- name: "Register instance-level runner with registration token (deprecated)" community.general.gitlab_runner: api_url: https://gitlab.example.com/ api_token: "{{ access_token }}" @@ -164,6 +205,7 @@ EXAMPLES = ''' tag_list: ['docker'] run_untagged: false locked: false + register: runner # Register module output to run C(gitlab-runner register) command in another task - name: "Delete runner" community.general.gitlab_runner: @@ -180,7 +222,7 @@ EXAMPLES = ''' owned: true state: absent -- name: Register runner for a specific project +- name: "Register a project-level runner with registration token (deprecated)" community.general.gitlab_runner: api_url: https://gitlab.example.com/ api_token: "{{ access_token }}" @@ -188,6 +230,7 @@ EXAMPLES = ''' description: MyProject runner state: present project: mygroup/mysubgroup/myproject + register: runner # Register module output to run C(gitlab-runner register) command in another task ''' RETURN = ''' @@ -423,6 +466,7 @@ def main(): state = module.params['state'] runner_description = module.params['description'] runner_active = module.params['active'] + runner_paused = module.params['paused'] tag_list = module.params['tag_list'] run_untagged = module.params['run_untagged'] runner_locked = module.params['locked'] @@ -457,7 +501,7 @@ def main(): module.exit_json(changed=False, msg="Runner deleted or does not exists") if state == 'present': - if gitlab_runner.create_or_update_runner(runner_description, { + runner_values = { "active": runner_active, "tag_list": tag_list, "run_untagged": run_untagged, @@ -467,7 +511,11 @@ def main(): "registration_token": registration_token, "group": group, "project": project, - }): + } + if LooseVersion(gitlab_runner._gitlab.version()[0]) >= LooseVersion("14.8.0"): + # the paused attribute for runners is available since 14.8 + runner_values["paused"] = runner_paused + if gitlab_runner.create_or_update_runner(runner_description, runner_values): module.exit_json(changed=True, runner=gitlab_runner.runner_object._attrs, msg="Successfully created or updated the runner %s" % runner_description) else: diff --git a/plugins/modules/haproxy.py b/plugins/modules/haproxy.py index cbaa438334..320c77e7a1 100644 --- a/plugins/modules/haproxy.py +++ b/plugins/modules/haproxy.py @@ -65,9 +65,8 @@ options: state: description: - Desired state of the provided backend host. - - Note that V(drain) state was added in version 2.4. - - It is supported only by HAProxy version 1.5 or later, - - When used on versions < 1.5, it will be ignored. + - Note that V(drain) state is supported only by HAProxy version 1.5 or later. + When used on versions < 1.5, it will be ignored. type: str required: true choices: [ disabled, drain, enabled ] diff --git a/plugins/modules/homebrew.py b/plugins/modules/homebrew.py index 5d471797a7..2b60846b43 100644 --- a/plugins/modules/homebrew.py +++ b/plugins/modules/homebrew.py @@ -76,6 +76,13 @@ options: type: list elements: str version_added: '0.2.0' + force_formula: + description: + - Force the package(s) to be treated as a formula (equivalent to C(brew --formula)). + - To install a cask, use the M(community.general.homebrew_cask) module. + type: bool + default: false + version_added: 9.0.0 notes: - When used with a C(loop:) each package will be processed individually, it is much more efficient to pass the list directly to the O(name) option. @@ -141,6 +148,12 @@ EXAMPLES = ''' community.general.homebrew: upgrade_all: true upgrade_options: ignore-pinned + +- name: Force installing a formula whose name is also a cask name + community.general.homebrew: + name: ambiguous_formula + state: present + force_formula: true ''' RETURN = ''' @@ -166,9 +179,10 @@ changed_pkgs: ''' import json -import os.path import re +from ansible_collections.community.general.plugins.module_utils.homebrew import HomebrewValidate + from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six import iteritems, string_types @@ -195,98 +209,7 @@ def _check_package_in_json(json_output, package_type): class Homebrew(object): '''A class to manage Homebrew packages.''' - # class regexes ------------------------------------------------ {{{ - VALID_PATH_CHARS = r''' - \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) - \s # spaces - : # colons - {sep} # the OS-specific path separator - . # dots - \- # dashes - '''.format(sep=os.path.sep) - - VALID_BREW_PATH_CHARS = r''' - \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) - \s # spaces - {sep} # the OS-specific path separator - . # dots - \- # dashes - '''.format(sep=os.path.sep) - - VALID_PACKAGE_CHARS = r''' - \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) - . # dots - / # slash (for taps) - \+ # plusses - \- # dashes - : # colons (for URLs) - @ # at-sign - ''' - - INVALID_PATH_REGEX = _create_regex_group_complement(VALID_PATH_CHARS) - INVALID_BREW_PATH_REGEX = _create_regex_group_complement(VALID_BREW_PATH_CHARS) - INVALID_PACKAGE_REGEX = _create_regex_group_complement(VALID_PACKAGE_CHARS) - # /class regexes ----------------------------------------------- }}} - # class validations -------------------------------------------- {{{ - @classmethod - def valid_path(cls, path): - ''' - `path` must be one of: - - list of paths - - a string containing only: - - alphanumeric characters - - dashes - - dots - - spaces - - colons - - os.path.sep - ''' - - if isinstance(path, string_types): - return not cls.INVALID_PATH_REGEX.search(path) - - try: - iter(path) - except TypeError: - return False - else: - paths = path - return all(cls.valid_brew_path(path_) for path_ in paths) - - @classmethod - def valid_brew_path(cls, brew_path): - ''' - `brew_path` must be one of: - - None - - a string containing only: - - alphanumeric characters - - dashes - - dots - - spaces - - os.path.sep - ''' - - if brew_path is None: - return True - - return ( - isinstance(brew_path, string_types) - and not cls.INVALID_BREW_PATH_REGEX.search(brew_path) - ) - - @classmethod - def valid_package(cls, package): - '''A valid package is either None or alphanumeric.''' - - if package is None: - return True - - return ( - isinstance(package, string_types) - and not cls.INVALID_PACKAGE_REGEX.search(package) - ) - @classmethod def valid_state(cls, state): ''' @@ -346,7 +269,7 @@ class Homebrew(object): @path.setter def path(self, path): - if not self.valid_path(path): + if not HomebrewValidate.valid_path(path): self._path = [] self.failed = True self.message = 'Invalid path: {0}.'.format(path) @@ -366,7 +289,7 @@ class Homebrew(object): @brew_path.setter def brew_path(self, brew_path): - if not self.valid_brew_path(brew_path): + if not HomebrewValidate.valid_brew_path(brew_path): self._brew_path = None self.failed = True self.message = 'Invalid brew_path: {0}.'.format(brew_path) @@ -391,7 +314,7 @@ class Homebrew(object): @current_package.setter def current_package(self, package): - if not self.valid_package(package): + if not HomebrewValidate.valid_package(package): self._current_package = None self.failed = True self.message = 'Invalid package: {0}.'.format(package) @@ -404,7 +327,8 @@ class Homebrew(object): def __init__(self, module, path, packages=None, state=None, update_homebrew=False, upgrade_all=False, - install_options=None, upgrade_options=None): + install_options=None, upgrade_options=None, + force_formula=False): if not install_options: install_options = list() if not upgrade_options: @@ -414,7 +338,8 @@ class Homebrew(object): state=state, update_homebrew=update_homebrew, upgrade_all=upgrade_all, install_options=install_options, - upgrade_options=upgrade_options,) + upgrade_options=upgrade_options, + force_formula=force_formula) self._prep() @@ -476,7 +401,7 @@ class Homebrew(object): # checks ------------------------------------------------------- {{{ def _current_package_is_installed(self): - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): self.failed = True self.message = 'Invalid package: {0}.'.format(self.current_package) raise HomebrewException(self.message) @@ -487,17 +412,19 @@ class Homebrew(object): "--json=v2", self.current_package, ] + if self.force_formula: + cmd.append("--formula") rc, out, err = self.module.run_command(cmd) - if err: + if rc != 0: self.failed = True - self.message = err.strip() + self.message = err.strip() or ("Unknown failure with exit code %d" % rc) raise HomebrewException(self.message) data = json.loads(out) return _check_package_in_json(data, "formulae") or _check_package_in_json(data, "casks") def _current_package_is_outdated(self): - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): return False rc, out, err = self.module.run_command([ @@ -509,7 +436,7 @@ class Homebrew(object): return rc != 0 def _current_package_is_installed_from_head(self): - if not Homebrew.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): return False elif not self._current_package_is_installed(): return False @@ -607,7 +534,7 @@ class Homebrew(object): # installed ------------------------------ {{{ def _install_current_package(self): - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): self.failed = True self.message = 'Invalid package: {0}.'.format(self.current_package) raise HomebrewException(self.message) @@ -632,10 +559,15 @@ class Homebrew(object): else: head = None + if self.force_formula: + formula = '--formula' + else: + formula = None + opts = ( [self.brew_path, 'install'] + self.install_options - + [self.current_package, head] + + [self.current_package, head, formula] ) cmd = [opt for opt in opts if opt] rc, out, err = self.module.run_command(cmd) @@ -663,7 +595,7 @@ class Homebrew(object): def _upgrade_current_package(self): command = 'upgrade' - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): self.failed = True self.message = 'Invalid package: {0}.'.format(self.current_package) raise HomebrewException(self.message) @@ -734,7 +666,7 @@ class Homebrew(object): # uninstalled ---------------------------- {{{ def _uninstall_current_package(self): - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): self.failed = True self.message = 'Invalid package: {0}.'.format(self.current_package) raise HomebrewException(self.message) @@ -783,7 +715,7 @@ class Homebrew(object): # linked --------------------------------- {{{ def _link_current_package(self): - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): self.failed = True self.message = 'Invalid package: {0}.'.format(self.current_package) raise HomebrewException(self.message) @@ -830,7 +762,7 @@ class Homebrew(object): # unlinked ------------------------------- {{{ def _unlink_current_package(self): - if not self.valid_package(self.current_package): + if not HomebrewValidate.valid_package(self.current_package): self.failed = True self.message = 'Invalid package: {0}.'.format(self.current_package) raise HomebrewException(self.message) @@ -919,7 +851,11 @@ def main(): default=None, type='list', elements='str', - ) + ), + force_formula=dict( + default=False, + type='bool', + ), ), supports_check_mode=True, ) @@ -951,6 +887,7 @@ def main(): if state in ('absent', 'removed', 'uninstalled'): state = 'absent' + force_formula = p['force_formula'] update_homebrew = p['update_homebrew'] if not update_homebrew: module.run_command_environ_update.update( @@ -967,7 +904,7 @@ def main(): brew = Homebrew(module=module, path=path, packages=packages, state=state, update_homebrew=update_homebrew, upgrade_all=upgrade_all, install_options=install_options, - upgrade_options=upgrade_options) + upgrade_options=upgrade_options, force_formula=force_formula) (failed, changed, message) = brew.run() changed_pkgs = brew.changed_pkgs unchanged_pkgs = brew.unchanged_pkgs diff --git a/plugins/modules/homebrew_cask.py b/plugins/modules/homebrew_cask.py index c992693b68..9902cb1373 100644 --- a/plugins/modules/homebrew_cask.py +++ b/plugins/modules/homebrew_cask.py @@ -158,6 +158,7 @@ import re import tempfile from ansible_collections.community.general.plugins.module_utils.version import LooseVersion +from ansible_collections.community.general.plugins.module_utils.homebrew import HomebrewValidate from ansible.module_utils.common.text.converters import to_bytes from ansible.module_utils.basic import AnsibleModule @@ -183,23 +184,6 @@ class HomebrewCask(object): '''A class to manage Homebrew casks.''' # class regexes ------------------------------------------------ {{{ - VALID_PATH_CHARS = r''' - \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) - \s # spaces - : # colons - {sep} # the OS-specific path separator - . # dots - \- # dashes - '''.format(sep=os.path.sep) - - VALID_BREW_PATH_CHARS = r''' - \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) - \s # spaces - {sep} # the OS-specific path separator - . # dots - \- # dashes - '''.format(sep=os.path.sep) - VALID_CASK_CHARS = r''' \w # alphanumeric characters (i.e., [a-zA-Z0-9_]) . # dots @@ -208,58 +192,10 @@ class HomebrewCask(object): @ # at symbol ''' - INVALID_PATH_REGEX = _create_regex_group_complement(VALID_PATH_CHARS) - INVALID_BREW_PATH_REGEX = _create_regex_group_complement(VALID_BREW_PATH_CHARS) INVALID_CASK_REGEX = _create_regex_group_complement(VALID_CASK_CHARS) # /class regexes ----------------------------------------------- }}} # class validations -------------------------------------------- {{{ - @classmethod - def valid_path(cls, path): - ''' - `path` must be one of: - - list of paths - - a string containing only: - - alphanumeric characters - - dashes - - dots - - spaces - - colons - - os.path.sep - ''' - - if isinstance(path, (string_types)): - return not cls.INVALID_PATH_REGEX.search(path) - - try: - iter(path) - except TypeError: - return False - else: - paths = path - return all(cls.valid_brew_path(path_) for path_ in paths) - - @classmethod - def valid_brew_path(cls, brew_path): - ''' - `brew_path` must be one of: - - None - - a string containing only: - - alphanumeric characters - - dashes - - dots - - spaces - - os.path.sep - ''' - - if brew_path is None: - return True - - return ( - isinstance(brew_path, string_types) - and not cls.INVALID_BREW_PATH_REGEX.search(brew_path) - ) - @classmethod def valid_cask(cls, cask): '''A valid cask is either None or alphanumeric + backslashes.''' @@ -321,7 +257,7 @@ class HomebrewCask(object): @path.setter def path(self, path): - if not self.valid_path(path): + if not HomebrewValidate.valid_path(path): self._path = [] self.failed = True self.message = 'Invalid path: {0}.'.format(path) @@ -341,7 +277,7 @@ class HomebrewCask(object): @brew_path.setter def brew_path(self, brew_path): - if not self.valid_brew_path(brew_path): + if not HomebrewValidate.valid_brew_path(brew_path): self._brew_path = None self.failed = True self.message = 'Invalid brew_path: {0}.'.format(brew_path) @@ -598,7 +534,12 @@ class HomebrewCask(object): rc, out, err = self.module.run_command(cmd) if rc == 0: - if re.search(r'==> No Casks to upgrade', out.strip(), re.IGNORECASE): + # 'brew upgrade --cask' does not output anything if no casks are upgraded + if not out.strip(): + self.message = 'Homebrew casks already upgraded.' + + # handle legacy 'brew cask upgrade' + elif re.search(r'==> No Casks to upgrade', out.strip(), re.IGNORECASE): self.message = 'Homebrew casks already upgraded.' else: diff --git a/plugins/modules/homebrew_services.py b/plugins/modules/homebrew_services.py new file mode 100644 index 0000000000..2794025b29 --- /dev/null +++ b/plugins/modules/homebrew_services.py @@ -0,0 +1,256 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2013, Andrew Dunham +# Copyright (c) 2013, Daniel Jaouen +# Copyright (c) 2015, Indrajit Raychaudhuri +# Copyright (c) 2024, Kit Ham +# +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = """ +--- +module: homebrew_services +author: + - "Kit Ham (@kitizz)" +requirements: + - homebrew must already be installed on the target system +short_description: Services manager for Homebrew +version_added: 9.3.0 +description: + - Manages daemons and services via Homebrew. +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + name: + description: + - An installed homebrew package whose service is to be updated. + aliases: [ 'formula' ] + type: str + required: true + path: + description: + - "A V(:) separated list of paths to search for C(brew) executable. + Since a package (I(formula) in homebrew parlance) location is prefixed relative to the actual path of C(brew) command, + providing an alternative C(brew) path enables managing different set of packages in an alternative location in the system." + default: '/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin' + type: path + state: + description: + - State of the package's service. + choices: [ 'present', 'absent', 'restarted' ] + default: present + type: str +""" + +EXAMPLES = """ +- name: Install foo package + community.general.homebrew: + name: foo + state: present + +- name: Start the foo service (equivalent to `brew services start foo`) + community.general.homebrew_service: + name: foo + state: present + +- name: Restart the foo service (equivalent to `brew services restart foo`) + community.general.homebrew_service: + name: foo + state: restarted + +- name: Remove the foo service (equivalent to `brew services stop foo`) + community.general.homebrew_service: + name: foo + service_state: absent +""" + +RETURN = """ +pid: + description: + - If the service is now running, this is the PID of the service, otherwise -1. + returned: success + type: int + sample: 1234 +running: + description: + - Whether the service is running after running this command. + returned: success + type: bool + sample: true +""" + +import json +import sys + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.homebrew import ( + HomebrewValidate, + parse_brew_path, +) + +if sys.version_info < (3, 5): + from collections import namedtuple + + # Stores validated arguments for an instance of an action. + # See DOCUMENTATION string for argument-specific information. + HomebrewServiceArgs = namedtuple( + "HomebrewServiceArgs", ["name", "state", "brew_path"] + ) + + # Stores the state of a Homebrew service. + HomebrewServiceState = namedtuple("HomebrewServiceState", ["running", "pid"]) + +else: + from typing import NamedTuple, Optional + + # Stores validated arguments for an instance of an action. + # See DOCUMENTATION string for argument-specific information. + HomebrewServiceArgs = NamedTuple( + "HomebrewServiceArgs", [("name", str), ("state", str), ("brew_path", str)] + ) + + # Stores the state of a Homebrew service. + HomebrewServiceState = NamedTuple( + "HomebrewServiceState", [("running", bool), ("pid", Optional[int])] + ) + + +def _brew_service_state(args, module): + # type: (HomebrewServiceArgs, AnsibleModule) -> HomebrewServiceState + cmd = [args.brew_path, "services", "info", args.name, "--json"] + rc, stdout, stderr = module.run_command(cmd, check_rc=True) + + try: + data = json.loads(stdout)[0] + except json.JSONDecodeError: + module.fail_json(msg="Failed to parse JSON output:\n{0}".format(stdout)) + + return HomebrewServiceState(running=data["status"] == "started", pid=data["pid"]) + + +def _exit_with_state(args, module, changed=False, message=None): + # type: (HomebrewServiceArgs, AnsibleModule, bool, Optional[str]) -> None + state = _brew_service_state(args, module) + if message is None: + message = ( + "Running: {state.running}, Changed: {changed}, PID: {state.pid}".format( + state=state, changed=changed + ) + ) + module.exit_json(msg=message, pid=state.pid, running=state.running, changed=changed) + + +def validate_and_load_arguments(module): + # type: (AnsibleModule) -> HomebrewServiceArgs + """Reuse the Homebrew module's validation logic to validate these arguments.""" + package = module.params["name"] # type: ignore + if not HomebrewValidate.valid_package(package): + module.fail_json(msg="Invalid package name: {0}".format(package)) + + state = module.params["state"] # type: ignore + if state not in ["present", "absent", "restarted"]: + module.fail_json(msg="Invalid state: {0}".format(state)) + + brew_path = parse_brew_path(module) + + return HomebrewServiceArgs(name=package, state=state, brew_path=brew_path) + + +def start_service(args, module): + # type: (HomebrewServiceArgs, AnsibleModule) -> None + """Start the requested brew service if it is not already running.""" + state = _brew_service_state(args, module) + if state.running: + # Nothing to do, return early. + _exit_with_state(args, module, changed=False, message="Service already running") + + if module.check_mode: + _exit_with_state(args, module, changed=True, message="Service would be started") + + start_cmd = [args.brew_path, "services", "start", args.name] + rc, stdout, stderr = module.run_command(start_cmd, check_rc=True) + + _exit_with_state(args, module, changed=True) + + +def stop_service(args, module): + # type: (HomebrewServiceArgs, AnsibleModule) -> None + """Stop the requested brew service if it is running.""" + state = _brew_service_state(args, module) + if not state.running: + # Nothing to do, return early. + _exit_with_state(args, module, changed=False, message="Service already stopped") + + if module.check_mode: + _exit_with_state(args, module, changed=True, message="Service would be stopped") + + stop_cmd = [args.brew_path, "services", "stop", args.name] + rc, stdout, stderr = module.run_command(stop_cmd, check_rc=True) + + _exit_with_state(args, module, changed=True) + + +def restart_service(args, module): + # type: (HomebrewServiceArgs, AnsibleModule) -> None + """Restart the requested brew service. This always results in a change.""" + if module.check_mode: + _exit_with_state( + args, module, changed=True, message="Service would be restarted" + ) + + restart_cmd = [args.brew_path, "services", "restart", args.name] + rc, stdout, stderr = module.run_command(restart_cmd, check_rc=True) + + _exit_with_state(args, module, changed=True) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict( + aliases=["formula"], + required=True, + type="str", + ), + state=dict( + choices=["present", "absent", "restarted"], + default="present", + ), + path=dict( + default="/usr/local/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + type="path", + ), + ), + supports_check_mode=True, + ) + + module.run_command_environ_update = dict( + LANG="C", LC_ALL="C", LC_MESSAGES="C", LC_CTYPE="C" + ) + + # Pre-validate arguments. + service_args = validate_and_load_arguments(module) + + # Choose logic based on the desired state. + if service_args.state == "present": + start_service(service_args, module) + elif service_args.state == "absent": + stop_service(service_args, module) + elif service_args.state == "restarted": + restart_service(service_args, module) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/homectl.py b/plugins/modules/homectl.py index ca4c19a875..7751651c85 100644 --- a/plugins/modules/homectl.py +++ b/plugins/modules/homectl.py @@ -17,6 +17,12 @@ short_description: Manage user accounts with systemd-homed version_added: 4.4.0 description: - Manages a user's home directory managed by systemd-homed. +notes: + - This module does B(not) work with Python 3.13 or newer. It uses the deprecated L(crypt Python module, + https://docs.python.org/3.12/library/crypt.html) from the Python standard library, which was removed + from Python 3.13. +requirements: + - Python 3.12 or earlier extends_documentation_fragment: - community.general.attributes attributes: @@ -263,12 +269,21 @@ data: } ''' -import crypt import json -from ansible.module_utils.basic import AnsibleModule +import traceback +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.basic import jsonify from ansible.module_utils.common.text.formatters import human_to_bytes +try: + import crypt +except ImportError: + HAS_CRYPT = False + CRYPT_IMPORT_ERROR = traceback.format_exc() +else: + HAS_CRYPT = True + CRYPT_IMPORT_ERROR = None + class Homectl(object): '''#TODO DOC STRINGS''' @@ -591,6 +606,12 @@ def main(): ] ) + if not HAS_CRYPT: + module.fail_json( + msg=missing_required_lib('crypt (part of Python 3.13 standard library)'), + exception=CRYPT_IMPORT_ERROR, + ) + homectl = Homectl(module) homectl.result['state'] = homectl.state diff --git a/plugins/modules/hponcfg.py b/plugins/modules/hponcfg.py index 612a20d923..206565a235 100644 --- a/plugins/modules/hponcfg.py +++ b/plugins/modules/hponcfg.py @@ -98,6 +98,7 @@ class HPOnCfg(ModuleHelper): verbose=cmd_runner_fmt.as_bool("-v"), minfw=cmd_runner_fmt.as_opt_val("-m"), ) + use_old_vardict = False def __run__(self): runner = CmdRunner( diff --git a/plugins/modules/hwc_ecs_instance.py b/plugins/modules/hwc_ecs_instance.py index 9ba95dc96d..cc6ef926dd 100644 --- a/plugins/modules/hwc_ecs_instance.py +++ b/plugins/modules/hwc_ecs_instance.py @@ -1163,8 +1163,7 @@ def send_delete_volume_request(module, params, client, info): path_parameters = { "volume_id": ["volume_id"], } - data = dict((key, navigate_value(info, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(info, path) for key, path in path_parameters.items()} url = build_path(module, "cloudservers/{id}/detachvolume/{volume_id}", data) diff --git a/plugins/modules/hwc_evs_disk.py b/plugins/modules/hwc_evs_disk.py index 7d445ddd21..5f0e40b196 100644 --- a/plugins/modules/hwc_evs_disk.py +++ b/plugins/modules/hwc_evs_disk.py @@ -771,8 +771,7 @@ def async_wait(config, result, client, timeout): path_parameters = { "job_id": ["job_id"], } - data = dict((key, navigate_value(result, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(result, path) for key, path in path_parameters.items()} url = build_path(module, "jobs/{job_id}", data) diff --git a/plugins/modules/hwc_vpc_eip.py b/plugins/modules/hwc_vpc_eip.py index 5c44319409..c3039ca2e5 100644 --- a/plugins/modules/hwc_vpc_eip.py +++ b/plugins/modules/hwc_vpc_eip.py @@ -547,8 +547,7 @@ def async_wait_create(config, result, client, timeout): path_parameters = { "publicip_id": ["publicip", "id"], } - data = dict((key, navigate_value(result, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(result, path) for key, path in path_parameters.items()} url = build_path(module, "publicips/{publicip_id}", data) diff --git a/plugins/modules/hwc_vpc_peering_connect.py b/plugins/modules/hwc_vpc_peering_connect.py index 2d6832ce5d..854d89e76a 100644 --- a/plugins/modules/hwc_vpc_peering_connect.py +++ b/plugins/modules/hwc_vpc_peering_connect.py @@ -407,8 +407,7 @@ def async_wait_create(config, result, client, timeout): path_parameters = { "peering_id": ["peering", "id"], } - data = dict((key, navigate_value(result, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(result, path) for key, path in path_parameters.items()} url = build_path(module, "v2.0/vpc/peerings/{peering_id}", data) diff --git a/plugins/modules/hwc_vpc_port.py b/plugins/modules/hwc_vpc_port.py index 2d830493d4..08b1c0607d 100644 --- a/plugins/modules/hwc_vpc_port.py +++ b/plugins/modules/hwc_vpc_port.py @@ -560,8 +560,7 @@ def async_wait_create(config, result, client, timeout): path_parameters = { "port_id": ["port", "id"], } - data = dict((key, navigate_value(result, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(result, path) for key, path in path_parameters.items()} url = build_path(module, "ports/{port_id}", data) diff --git a/plugins/modules/hwc_vpc_subnet.py b/plugins/modules/hwc_vpc_subnet.py index 7ba7473301..ff6e425ca9 100644 --- a/plugins/modules/hwc_vpc_subnet.py +++ b/plugins/modules/hwc_vpc_subnet.py @@ -440,8 +440,7 @@ def async_wait_create(config, result, client, timeout): path_parameters = { "subnet_id": ["subnet", "id"], } - data = dict((key, navigate_value(result, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(result, path) for key, path in path_parameters.items()} url = build_path(module, "subnets/{subnet_id}", data) @@ -538,8 +537,7 @@ def async_wait_update(config, result, client, timeout): path_parameters = { "subnet_id": ["subnet", "id"], } - data = dict((key, navigate_value(result, path)) - for key, path in path_parameters.items()) + data = {key: navigate_value(result, path) for key, path in path_parameters.items()} url = build_path(module, "subnets/{subnet_id}", data) diff --git a/plugins/modules/imc_rest.py b/plugins/modules/imc_rest.py index 113d341e89..946dfe7f10 100644 --- a/plugins/modules/imc_rest.py +++ b/plugins/modules/imc_rest.py @@ -268,7 +268,6 @@ output: errorDescr="XML PARSING ERROR: Element 'computeRackUnit', attribute 'admin_Power': The attribute 'admin_Power' is not allowed.\n"/> ''' -import datetime import os import traceback @@ -292,6 +291,10 @@ from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.six.moves import zip_longest from ansible.module_utils.urls import fetch_url +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + def imc_response(module, rawoutput, rawinput=''): ''' Handle IMC returned data ''' @@ -320,8 +323,7 @@ def merge(one, two): ''' Merge two complex nested datastructures into one''' if isinstance(one, dict) and isinstance(two, dict): copy = dict(one) - # copy.update({key: merge(one.get(key, None), two[key]) for key in two}) - copy.update(dict((key, merge(one.get(key, None), two[key])) for key in two)) + copy.update({key: merge(one.get(key, None), two[key]) for key in two}) return copy elif isinstance(one, list) and isinstance(two, list): @@ -375,14 +377,14 @@ def main(): else: module.fail_json(msg='Cannot find/access path:\n%s' % path) - start = datetime.datetime.utcnow() + start = now() # Perform login first url = '%s://%s/nuova' % (protocol, hostname) data = '' % (username, password) resp, auth = fetch_url(module, url, data=data, method='POST', timeout=timeout) if resp is None or auth['status'] != 200: - result['elapsed'] = (datetime.datetime.utcnow() - start).seconds + result['elapsed'] = (now() - start).seconds module.fail_json(msg='Task failed with error %(status)s: %(msg)s' % auth, **result) result.update(imc_response(module, resp.read())) @@ -415,7 +417,7 @@ def main(): # Perform actual request resp, info = fetch_url(module, url, data=data, method='POST', timeout=timeout) if resp is None or info['status'] != 200: - result['elapsed'] = (datetime.datetime.utcnow() - start).seconds + result['elapsed'] = (now() - start).seconds module.fail_json(msg='Task failed with error %(status)s: %(msg)s' % info, **result) # Merge results with previous results @@ -431,7 +433,7 @@ def main(): result['changed'] = ('modified' in results) # Report success - result['elapsed'] = (datetime.datetime.utcnow() - start).seconds + result['elapsed'] = (now() - start).seconds module.exit_json(**result) finally: logout(module, url, cookie, timeout) diff --git a/plugins/modules/ini_file.py b/plugins/modules/ini_file.py index ec71a94731..affee2a4f7 100644 --- a/plugins/modules/ini_file.py +++ b/plugins/modules/ini_file.py @@ -44,6 +44,30 @@ options: - If being omitted, the O(option) will be placed before the first O(section). - Omitting O(section) is also required if the config format does not support sections. type: str + section_has_values: + type: list + elements: dict + required: false + suboptions: + option: + type: str + description: Matching O(section) must contain this option. + required: true + value: + type: str + description: Matching O(section_has_values[].option) must have this specific value. + values: + description: + - The string value to be associated with an O(section_has_values[].option). + - Mutually exclusive with O(section_has_values[].value). + - O(section_has_values[].value=v) is equivalent to O(section_has_values[].values=[v]). + type: list + elements: str + description: + - Among possibly multiple sections of the same name, select the first one that contains matching options and values. + - With O(state=present), if a suitable section is not found, a new section will be added, including the required options. + - With O(state=absent), at most one O(section) is removed if it contains the values. + version_added: 8.6.0 option: description: - If set (required for changing a O(value)), this is the name of the option. @@ -182,6 +206,57 @@ EXAMPLES = r''' option: beverage value: lemon juice state: present + +- name: Remove the peer configuration for 10.128.0.11/32 + community.general.ini_file: + path: /etc/wireguard/wg0.conf + section: Peer + section_has_values: + - option: AllowedIps + value: 10.128.0.11/32 + mode: '0600' + state: absent + +- name: Add "beverage=lemon juice" outside a section in specified file + community.general.ini_file: + path: /etc/conf + option: beverage + value: lemon juice + state: present + +- name: Update the public key for peer 10.128.0.12/32 + community.general.ini_file: + path: /etc/wireguard/wg0.conf + section: Peer + section_has_values: + - option: AllowedIps + value: 10.128.0.12/32 + option: PublicKey + value: xxxxxxxxxxxxxxxxxxxx + mode: '0600' + state: present + +- name: Remove the peer configuration for 10.128.0.11/32 + community.general.ini_file: + path: /etc/wireguard/wg0.conf + section: Peer + section_has_values: + - option: AllowedIps + value: 10.4.0.11/32 + mode: '0600' + state: absent + +- name: Update the public key for peer 10.128.0.12/32 + community.general.ini_file: + path: /etc/wireguard/wg0.conf + section: Peer + section_has_values: + - option: AllowedIps + value: 10.4.0.12/32 + option: PublicKey + value: xxxxxxxxxxxxxxxxxxxx + mode: '0600' + state: present ''' import io @@ -222,7 +297,19 @@ def update_section_line(option, changed, section_lines, index, changed_lines, ig return (changed, msg) -def do_ini(module, filename, section=None, option=None, values=None, +def check_section_has_values(section_has_values, section_lines): + if section_has_values is not None: + for condition in section_has_values: + for line in section_lines: + match = match_opt(condition["option"], line) + if match and (len(condition["values"]) == 0 or match.group(7) in condition["values"]): + break + else: + return False + return True + + +def do_ini(module, filename, section=None, section_has_values=None, option=None, values=None, state='present', exclusive=True, backup=False, no_extra_spaces=False, ignore_spaces=False, create=True, allow_no_value=False, modify_inactive_option=True, follow=False): @@ -307,14 +394,22 @@ def do_ini(module, filename, section=None, option=None, values=None, section_pattern = re.compile(to_text(r'^\[\s*%s\s*]' % re.escape(section.strip()))) for index, line in enumerate(ini_lines): + # end of section: + if within_section and line.startswith(u'['): + if check_section_has_values( + section_has_values, ini_lines[section_start:index] + ): + section_end = index + break + else: + # look for another section + within_section = False + section_start = section_end = 0 + # find start and end of section if section_pattern.match(line): within_section = True section_start = index - elif line.startswith(u'['): - if within_section: - section_end = index - break before = ini_lines[0:section_start] section_lines = ini_lines[section_start:section_end] @@ -435,6 +530,18 @@ def do_ini(module, filename, section=None, option=None, values=None, if not within_section and state == 'present': ini_lines.append(u'[%s]\n' % section) msg = 'section and option added' + if section_has_values: + for condition in section_has_values: + if condition['option'] != option: + if len(condition['values']) > 0: + for value in condition['values']: + ini_lines.append(assignment_format % (condition['option'], value)) + elif allow_no_value: + ini_lines.append(u'%s\n' % condition['option']) + elif not exclusive: + for value in condition['values']: + if value not in values: + values.append(value) if option and values: for value in values: ini_lines.append(assignment_format % (option, value)) @@ -476,6 +583,11 @@ def main(): argument_spec=dict( path=dict(type='path', required=True, aliases=['dest']), section=dict(type='str'), + section_has_values=dict(type='list', elements='dict', options=dict( + option=dict(type='str', required=True), + value=dict(type='str'), + values=dict(type='list', elements='str') + ), default=None, mutually_exclusive=[['value', 'values']]), option=dict(type='str'), value=dict(type='str'), values=dict(type='list', elements='str'), @@ -498,6 +610,7 @@ def main(): path = module.params['path'] section = module.params['section'] + section_has_values = module.params['section_has_values'] option = module.params['option'] value = module.params['value'] values = module.params['values'] @@ -519,8 +632,16 @@ def main(): elif values is None: values = [] + if section_has_values: + for condition in section_has_values: + if condition['value'] is not None: + condition['values'] = [condition['value']] + elif condition['values'] is None: + condition['values'] = [] +# raise Exception("section_has_values: {}".format(section_has_values)) + (changed, backup_file, diff, msg) = do_ini( - module, path, section, option, values, state, exclusive, backup, + module, path, section, section_has_values, option, values, state, exclusive, backup, no_extra_spaces, ignore_spaces, create, allow_no_value, modify_inactive_option, follow) if not module.check_mode and os.path.exists(path): diff --git a/plugins/modules/installp.py b/plugins/modules/installp.py index 4b5a6949c6..1531d2cad2 100644 --- a/plugins/modules/installp.py +++ b/plugins/modules/installp.py @@ -106,7 +106,7 @@ def _check_new_pkg(module, package, repository_path): if os.path.isdir(repository_path): installp_cmd = module.get_bin_path('installp', True) - rc, package_result, err = module.run_command("%s -l -MR -d %s" % (installp_cmd, repository_path)) + rc, package_result, err = module.run_command([installp_cmd, "-l", "-MR", "-d", repository_path]) if rc != 0: module.fail_json(msg="Failed to run installp.", rc=rc, err=err) @@ -142,7 +142,7 @@ def _check_installed_pkg(module, package, repository_path): """ lslpp_cmd = module.get_bin_path('lslpp', True) - rc, lslpp_result, err = module.run_command("%s -lcq %s*" % (lslpp_cmd, package)) + rc, lslpp_result, err = module.run_command([lslpp_cmd, "-lcq", "%s*" % (package, )]) if rc == 1: package_state = ' '.join(err.split()[-2:]) @@ -173,7 +173,7 @@ def remove(module, installp_cmd, packages): if pkg_check: if not module.check_mode: - rc, remove_out, err = module.run_command("%s -u %s" % (installp_cmd, package)) + rc, remove_out, err = module.run_command([installp_cmd, "-u", package]) if rc != 0: module.fail_json(msg="Failed to run installp.", rc=rc, err=err) remove_count += 1 @@ -202,8 +202,8 @@ def install(module, installp_cmd, packages, repository_path, accept_license): already_installed_pkgs = {} accept_license_param = { - True: '-Y', - False: '', + True: ['-Y'], + False: [], } # Validate if package exists on repository path. @@ -230,7 +230,8 @@ def install(module, installp_cmd, packages, repository_path, accept_license): else: if not module.check_mode: - rc, out, err = module.run_command("%s -a %s -X -d %s %s" % (installp_cmd, accept_license_param[accept_license], repository_path, package)) + rc, out, err = module.run_command( + [installp_cmd, "-a"] + accept_license_param[accept_license] + ["-X", "-d", repository_path, package]) if rc != 0: module.fail_json(msg="Failed to run installp", rc=rc, err=err) installed_pkgs.append(package) diff --git a/plugins/modules/ipa_dnsrecord.py b/plugins/modules/ipa_dnsrecord.py index cb4ce03ddd..1dad138377 100644 --- a/plugins/modules/ipa_dnsrecord.py +++ b/plugins/modules/ipa_dnsrecord.py @@ -35,42 +35,42 @@ options: record_type: description: - The type of DNS record name. - - Currently, 'A', 'AAAA', 'A6', 'CNAME', 'DNAME', 'NS', 'PTR', 'TXT', 'SRV' and 'MX' are supported. - - "'A6', 'CNAME', 'DNAME' and 'TXT' are added in version 2.5." - - "'SRV' and 'MX' are added in version 2.8." - - "'NS' are added in comunity.general 8.2.0." + - Support for V(NS) was added in comunity.general 8.2.0. + - Support for V(SSHFP) was added in community.general 9.1.0. required: false default: 'A' - choices: ['A', 'AAAA', 'A6', 'CNAME', 'DNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT'] + choices: ['A', 'AAAA', 'A6', 'CNAME', 'DNAME', 'MX', 'NS', 'PTR', 'SRV', 'TXT', 'SSHFP'] type: str record_value: description: - Manage DNS record name with this value. - Mutually exclusive with O(record_values), and exactly one of O(record_value) and O(record_values) has to be specified. - Use O(record_values) if you need to specify multiple values. - - In the case of 'A' or 'AAAA' record types, this will be the IP address. - - In the case of 'A6' record type, this will be the A6 Record data. - - In the case of 'CNAME' record type, this will be the hostname. - - In the case of 'DNAME' record type, this will be the DNAME target. - - In the case of 'NS' record type, this will be the name server hostname. Hostname must already have a valid A or AAAA record. - - In the case of 'PTR' record type, this will be the hostname. - - In the case of 'TXT' record type, this will be a text. - - In the case of 'SRV' record type, this will be a service record. - - In the case of 'MX' record type, this will be a mail exchanger record. + - In the case of V(A) or V(AAAA) record types, this will be the IP address. + - In the case of V(A6) record type, this will be the A6 Record data. + - In the case of V(CNAME) record type, this will be the hostname. + - In the case of V(DNAME) record type, this will be the DNAME target. + - In the case of V(NS) record type, this will be the name server hostname. Hostname must already have a valid A or AAAA record. + - In the case of V(PTR) record type, this will be the hostname. + - In the case of V(TXT) record type, this will be a text. + - In the case of V(SRV) record type, this will be a service record. + - In the case of V(MX) record type, this will be a mail exchanger record. + - In the case of V(SSHFP) record type, this will be an SSH fingerprint record. type: str record_values: description: - Manage DNS record name with this value. - Mutually exclusive with O(record_value), and exactly one of O(record_value) and O(record_values) has to be specified. - - In the case of 'A' or 'AAAA' record types, this will be the IP address. - - In the case of 'A6' record type, this will be the A6 Record data. - - In the case of 'CNAME' record type, this will be the hostname. - - In the case of 'DNAME' record type, this will be the DNAME target. - - In the case of 'NS' record type, this will be the name server hostname. Hostname must already have a valid A or AAAA record. - - In the case of 'PTR' record type, this will be the hostname. - - In the case of 'TXT' record type, this will be a text. - - In the case of 'SRV' record type, this will be a service record. - - In the case of 'MX' record type, this will be a mail exchanger record. + - In the case of V(A) or V(AAAA) record types, this will be the IP address. + - In the case of V(A6) record type, this will be the A6 Record data. + - In the case of V(CNAME) record type, this will be the hostname. + - In the case of V(DNAME) record type, this will be the DNAME target. + - In the case of V(NS) record type, this will be the name server hostname. Hostname must already have a valid A or AAAA record. + - In the case of V(PTR) record type, this will be the hostname. + - In the case of V(TXT) record type, this will be a text. + - In the case of V(SRV) record type, this will be a service record. + - In the case of V(MX) record type, this will be a mail exchanger record. + - In the case of V(SSHFP) record type, this will be an SSH fingerprint record. type: list elements: str record_ttl: @@ -175,6 +175,20 @@ EXAMPLES = r''' ipa_host: ipa.example.com ipa_user: admin ipa_pass: ChangeMe! + +- name: Retrieve the current sshfp fingerprints + ansible.builtin.command: ssh-keyscan -D localhost + register: ssh_hostkeys + +- name: Update the SSHFP records in DNS + community.general.ipa_dnsrecord: + name: "{{ inventory_hostname}}" + zone_name: example.com + record_type: 'SSHFP' + record_values: "{{ ssh_hostkeys.stdout.split('\n') | map('split', 'SSHFP ') | map('last') | list }}" + ipa_host: ipa.example.com + ipa_user: admin + ipa_pass: ChangeMe! ''' RETURN = r''' @@ -228,6 +242,8 @@ class DNSRecordIPAClient(IPAClient): item.update(srvrecord=value) elif details['record_type'] == 'MX': item.update(mxrecord=value) + elif details['record_type'] == 'SSHFP': + item.update(sshfprecord=value) self._post_json(method='dnsrecord_add', name=zone_name, item=item) @@ -266,6 +282,8 @@ def get_dnsrecord_dict(details=None): module_dnsrecord.update(srvrecord=details['record_values']) elif details['record_type'] == 'MX' and details['record_values']: module_dnsrecord.update(mxrecord=details['record_values']) + elif details['record_type'] == 'SSHFP' and details['record_values']: + module_dnsrecord.update(sshfprecord=details['record_values']) if details.get('record_ttl'): module_dnsrecord.update(dnsttl=details['record_ttl']) @@ -328,7 +346,7 @@ def ensure(module, client): def main(): - record_types = ['A', 'AAAA', 'A6', 'CNAME', 'DNAME', 'NS', 'PTR', 'TXT', 'SRV', 'MX'] + record_types = ['A', 'AAAA', 'A6', 'CNAME', 'DNAME', 'NS', 'PTR', 'TXT', 'SRV', 'MX', 'SSHFP'] argument_spec = ipa_argument_spec() argument_spec.update( zone_name=dict(type='str', required=True), diff --git a/plugins/modules/ipa_otptoken.py b/plugins/modules/ipa_otptoken.py index 567674f935..d8a5b3cf1d 100644 --- a/plugins/modules/ipa_otptoken.py +++ b/plugins/modules/ipa_otptoken.py @@ -392,9 +392,7 @@ def ensure(module, client): 'counter': 'ipatokenhotpcounter'} # Create inverse dictionary for mapping return values - ipa_to_ansible = {} - for (k, v) in ansible_to_ipa.items(): - ipa_to_ansible[v] = k + ipa_to_ansible = {v: k for k, v in ansible_to_ipa.items()} unmodifiable_after_creation = ['otptype', 'secretkey', 'algorithm', 'digits', 'offset', 'interval', 'counter'] diff --git a/plugins/modules/iptables_state.py b/plugins/modules/iptables_state.py index b0cc3bd3f6..c97b5694c9 100644 --- a/plugins/modules/iptables_state.py +++ b/plugins/modules/iptables_state.py @@ -459,6 +459,7 @@ def main(): if not os.access(b_path, os.R_OK): module.fail_json(msg="Source %s not readable" % path) state_to_restore = read_state(b_path) + cmd = None else: cmd = ' '.join(SAVECOMMAND) diff --git a/plugins/modules/java_cert.py b/plugins/modules/java_cert.py index 72302b12c1..e2d04b71e2 100644 --- a/plugins/modules/java_cert.py +++ b/plugins/modules/java_cert.py @@ -28,7 +28,7 @@ options: cert_url: description: - Basic URL to fetch SSL certificate from. - - Exactly one of O(cert_url), O(cert_path), or O(pkcs12_path) is required to load certificate. + - Exactly one of O(cert_url), O(cert_path), O(cert_content), or O(pkcs12_path) is required to load certificate. type: str cert_port: description: @@ -39,8 +39,14 @@ options: cert_path: description: - Local path to load certificate from. - - Exactly one of O(cert_url), O(cert_path), or O(pkcs12_path) is required to load certificate. + - Exactly one of O(cert_url), O(cert_path), O(cert_content), or O(pkcs12_path) is required to load certificate. type: path + cert_content: + description: + - Content of the certificate used to create the keystore. + - Exactly one of O(cert_url), O(cert_path), O(cert_content), or O(pkcs12_path) is required to load certificate. + type: str + version_added: 8.6.0 cert_alias: description: - Imported certificate alias. @@ -55,10 +61,10 @@ options: pkcs12_path: description: - Local path to load PKCS12 keystore from. - - Unlike O(cert_url) and O(cert_path), the PKCS12 keystore embeds the private key matching + - Unlike O(cert_url), O(cert_path) and O(cert_content), the PKCS12 keystore embeds the private key matching the certificate, and is used to import both the certificate and its private key into the java keystore. - - Exactly one of O(cert_url), O(cert_path), or O(pkcs12_path) is required to load certificate. + - Exactly one of O(cert_url), O(cert_path), O(cert_content), or O(pkcs12_path) is required to load certificate. type: path pkcs12_password: description: @@ -149,6 +155,19 @@ EXAMPLES = r''' cert_alias: LE_RootCA trust_cacert: true +- name: Import trusted CA from the SSL certificate stored in the cert_content variable + community.general.java_cert: + cert_content: | + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + keystore_path: /tmp/cacerts + keystore_pass: changeit + keystore_create: true + state: present + cert_alias: LE_RootCA + trust_cacert: true + - name: Import SSL certificate from google.com to a keystore, create it if it doesn't exist community.general.java_cert: cert_url: google.com @@ -487,6 +506,7 @@ def main(): argument_spec = dict( cert_url=dict(type='str'), cert_path=dict(type='path'), + cert_content=dict(type='str'), pkcs12_path=dict(type='path'), pkcs12_password=dict(type='str', no_log=True), pkcs12_alias=dict(type='str'), @@ -503,11 +523,11 @@ def main(): module = AnsibleModule( argument_spec=argument_spec, - required_if=[['state', 'present', ('cert_path', 'cert_url', 'pkcs12_path'), True], + required_if=[['state', 'present', ('cert_path', 'cert_url', 'cert_content', 'pkcs12_path'), True], ['state', 'absent', ('cert_url', 'cert_alias'), True]], required_together=[['keystore_path', 'keystore_pass']], mutually_exclusive=[ - ['cert_url', 'cert_path', 'pkcs12_path'] + ['cert_url', 'cert_path', 'cert_content', 'pkcs12_path'] ], supports_check_mode=True, add_file_common_args=True, @@ -515,6 +535,7 @@ def main(): url = module.params.get('cert_url') path = module.params.get('cert_path') + content = module.params.get('cert_content') port = module.params.get('cert_port') pkcs12_path = module.params.get('pkcs12_path') @@ -582,6 +603,10 @@ def main(): # certificate to stdout so we don't need to do any transformations. new_certificate = path + elif content: + with open(new_certificate, "w") as f: + f.write(content) + elif url: # Getting the X509 digest from a URL is the same as from a path, we just have # to download the cert first diff --git a/plugins/modules/jira.py b/plugins/modules/jira.py index c36cf99375..0bb95158f7 100644 --- a/plugins/modules/jira.py +++ b/plugins/modules/jira.py @@ -531,7 +531,7 @@ class JIRA(StateModuleHelper): ), supports_check_mode=False ) - + mute_vardict_deprecation = True state_param = 'operation' def __init_module__(self): @@ -544,7 +544,7 @@ class JIRA(StateModuleHelper): self.vars.uri = self.vars.uri.strip('/') self.vars.set('restbase', self.vars.uri + '/rest/api/2') - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_create(self): createfields = { 'project': {'key': self.vars.project}, @@ -562,7 +562,7 @@ class JIRA(StateModuleHelper): url = self.vars.restbase + '/issue/' self.vars.meta = self.post(url, data) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_comment(self): data = { 'body': self.vars.comment @@ -578,7 +578,7 @@ class JIRA(StateModuleHelper): url = self.vars.restbase + '/issue/' + self.vars.issue + '/comment' self.vars.meta = self.post(url, data) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_worklog(self): data = { 'comment': self.vars.comment @@ -594,7 +594,7 @@ class JIRA(StateModuleHelper): url = self.vars.restbase + '/issue/' + self.vars.issue + '/worklog' self.vars.meta = self.post(url, data) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_edit(self): data = { 'fields': self.vars.fields @@ -602,7 +602,7 @@ class JIRA(StateModuleHelper): url = self.vars.restbase + '/issue/' + self.vars.issue self.vars.meta = self.put(url, data) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_update(self): data = { "update": self.vars.fields, @@ -624,7 +624,7 @@ class JIRA(StateModuleHelper): self.vars.meta = self.get(url) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_transition(self): # Find the transition id turl = self.vars.restbase + '/issue/' + self.vars.issue + "/transitions" @@ -657,7 +657,7 @@ class JIRA(StateModuleHelper): url = self.vars.restbase + '/issue/' + self.vars.issue + "/transitions" self.vars.meta = self.post(url, data) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_link(self): data = { 'type': {'name': self.vars.linktype}, @@ -667,7 +667,7 @@ class JIRA(StateModuleHelper): url = self.vars.restbase + '/issueLink/' self.vars.meta = self.post(url, data) - @cause_changes(on_success=True) + @cause_changes(when="success") def operation_attach(self): v = self.vars filename = v.attachment.get('filename') diff --git a/plugins/modules/kernel_blacklist.py b/plugins/modules/kernel_blacklist.py index b5bd904036..224b5bba8c 100644 --- a/plugins/modules/kernel_blacklist.py +++ b/plugins/modules/kernel_blacklist.py @@ -67,6 +67,7 @@ class Blacklist(StateModuleHelper): ), supports_check_mode=True, ) + use_old_vardict = False def __init_module__(self): self.pattern = re.compile(r'^blacklist\s+{0}$'.format(re.escape(self.vars.name))) diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py index b151e4541f..d7e4fb0b7e 100644 --- a/plugins/modules/keycloak_client.py +++ b/plugins/modules/keycloak_client.py @@ -248,8 +248,9 @@ options: description: - Type of client. - At creation only, default value will be V(openid-connect) if O(protocol) is omitted. + - The V(docker-v2) value was added in community.general 8.6.0. type: str - choices: ['openid-connect', 'saml'] + choices: ['openid-connect', 'saml', 'docker-v2'] full_scope_allowed: description: @@ -339,6 +340,42 @@ options: description: - Override realm authentication flow bindings. type: dict + suboptions: + browser: + description: + - Flow ID of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) + and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. + type: str + + browser_name: + description: + - Flow name of the browser authentication flow. + - O(authentication_flow_binding_overrides.browser) + and O(authentication_flow_binding_overrides.browser_name) are mutually exclusive. + aliases: + - browserName + type: str + version_added: 9.1.0 + + direct_grant: + description: + - Flow ID of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) + and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. + aliases: + - directGrant + type: str + + direct_grant_name: + description: + - Flow name of the direct grant authentication flow. + - O(authentication_flow_binding_overrides.direct_grant) + and O(authentication_flow_binding_overrides.direct_grant_name) are mutually exclusive. + aliases: + - directGrantName + type: str + version_added: 9.1.0 aliases: - authenticationFlowBindingOverrides version_added: 3.4.0 @@ -393,7 +430,7 @@ options: protocol: description: - This specifies for which protocol this protocol mapper is active. - choices: ['openid-connect', 'saml'] + choices: ['openid-connect', 'saml', 'docker-v2'] type: str protocolMapper: @@ -724,6 +761,7 @@ import copy PROTOCOL_OPENID_CONNECT = 'openid-connect' PROTOCOL_SAML = 'saml' +PROTOCOL_DOCKER_V2 = 'docker-v2' CLIENT_META_DATA = ['authorizationServicesEnabled'] @@ -742,6 +780,12 @@ def normalise_cr(clientrep, remove_ids=False): if 'attributes' in clientrep: clientrep['attributes'] = list(sorted(clientrep['attributes'])) + if 'defaultClientScopes' in clientrep: + clientrep['defaultClientScopes'] = list(sorted(clientrep['defaultClientScopes'])) + + if 'optionalClientScopes' in clientrep: + clientrep['optionalClientScopes'] = list(sorted(clientrep['optionalClientScopes'])) + if 'redirectUris' in clientrep: clientrep['redirectUris'] = list(sorted(clientrep['redirectUris'])) @@ -767,11 +811,70 @@ def sanitize_cr(clientrep): if 'secret' in result: result['secret'] = 'no_log' if 'attributes' in result: - if 'saml.signing.private.key' in result['attributes']: - result['attributes']['saml.signing.private.key'] = 'no_log' + attributes = result['attributes'] + if isinstance(attributes, dict) and 'saml.signing.private.key' in attributes: + attributes['saml.signing.private.key'] = 'no_log' return normalise_cr(result) +def get_authentication_flow_id(flow_name, realm, kc): + """ Get the authentication flow ID based on the flow name, realm, and Keycloak client. + + Args: + flow_name (str): The name of the authentication flow. + realm (str): The name of the realm. + kc (KeycloakClient): The Keycloak client instance. + + Returns: + str: The ID of the authentication flow. + + Raises: + KeycloakAPIException: If the authentication flow with the given name is not found in the realm. + """ + flow = kc.get_authentication_flow_by_alias(flow_name, realm) + if flow: + return flow["id"] + kc.module.fail_json(msg='Authentification flow %s not found in realm %s' % (flow_name, realm)) + + +def flow_binding_from_dict_to_model(newClientFlowBinding, realm, kc): + """ Convert a dictionary representing client flow bindings to a model representation. + + Args: + newClientFlowBinding (dict): A dictionary containing client flow bindings. + realm (str): The name of the realm. + kc (KeycloakClient): An instance of the KeycloakClient class. + + Returns: + dict: A dictionary representing the model flow bindings. The dictionary has two keys: + - "browser" (str or None): The ID of the browser authentication flow binding, or None if not provided. + - "direct_grant" (str or None): The ID of the direct grant authentication flow binding, or None if not provided. + + Raises: + KeycloakAPIException: If the authentication flow with the given name is not found in the realm. + + """ + + modelFlow = { + "browser": None, + "direct_grant": None + } + + for k, v in newClientFlowBinding.items(): + if not v: + continue + if k == "browser": + modelFlow["browser"] = v + elif k == "browser_name": + modelFlow["browser"] = get_authentication_flow_id(v, realm, kc) + elif k == "direct_grant": + modelFlow["direct_grant"] = v + elif k == "direct_grant_name": + modelFlow["direct_grant"] = get_authentication_flow_id(v, realm, kc) + + return modelFlow + + def main(): """ Module execution @@ -785,11 +888,18 @@ def main(): consentText=dict(type='str'), id=dict(type='str'), name=dict(type='str'), - protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML]), + protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), protocolMapper=dict(type='str'), config=dict(type='dict'), ) + authentication_flow_spec = dict( + browser=dict(type='str'), + browser_name=dict(type='str', aliases=['browserName']), + direct_grant=dict(type='str', aliases=['directGrant']), + direct_grant_name=dict(type='str', aliases=['directGrantName']), + ) + meta_args = dict( state=dict(default='present', choices=['present', 'absent']), realm=dict(type='str', default='master'), @@ -819,7 +929,7 @@ def main(): authorization_services_enabled=dict(type='bool', aliases=['authorizationServicesEnabled']), public_client=dict(type='bool', aliases=['publicClient']), frontchannel_logout=dict(type='bool', aliases=['frontchannelLogout']), - protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML]), + protocol=dict(type='str', choices=[PROTOCOL_OPENID_CONNECT, PROTOCOL_SAML, PROTOCOL_DOCKER_V2]), attributes=dict(type='dict'), full_scope_allowed=dict(type='bool', aliases=['fullScopeAllowed']), node_re_registration_timeout=dict(type='int', aliases=['nodeReRegistrationTimeout']), @@ -829,7 +939,13 @@ def main(): use_template_scope=dict(type='bool', aliases=['useTemplateScope']), use_template_mappers=dict(type='bool', aliases=['useTemplateMappers']), always_display_in_console=dict(type='bool', aliases=['alwaysDisplayInConsole']), - authentication_flow_binding_overrides=dict(type='dict', aliases=['authenticationFlowBindingOverrides']), + authentication_flow_binding_overrides=dict( + type='dict', + aliases=['authenticationFlowBindingOverrides'], + options=authentication_flow_spec, + required_one_of=[['browser', 'direct_grant', 'browser_name', 'direct_grant_name']], + mutually_exclusive=[['browser', 'browser_name'], ['direct_grant', 'direct_grant_name']], + ), protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), authorization_settings=dict(type='dict', aliases=['authorizationSettings']), default_client_scopes=dict(type='list', elements='str', aliases=['defaultClientScopes']), @@ -890,7 +1006,9 @@ def main(): # Unfortunately, the ansible argument spec checker introduces variables with null values when # they are not specified if client_param == 'protocol_mappers': - new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] + elif client_param == 'authentication_flow_binding_overrides': + new_param_value = flow_binding_from_dict_to_model(new_param_value, realm, kc) changeset[camel(client_param)] = new_param_value diff --git a/plugins/modules/keycloak_client_rolescope.py b/plugins/modules/keycloak_client_rolescope.py new file mode 100644 index 0000000000..cca72f0ddd --- /dev/null +++ b/plugins/modules/keycloak_client_rolescope.py @@ -0,0 +1,280 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_client_rolescope + +short_description: Allows administration of Keycloak client roles scope to restrict the usage of certain roles to a other specific client applications. + +version_added: 8.6.0 + +description: + - This module allows you to add or remove Keycloak roles from clients scope via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - Client O(client_id) must have O(community.general.keycloak_client#module:full_scope_allowed) set to V(false). + + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will + be returned that way by this module. You may pass single values for attributes when calling the module, + and this will be translated into a list suitable for the API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the role mapping. + - On V(present), all roles in O(role_names) will be mapped if not exists yet. + - On V(absent), all roles mapping in O(role_names) will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - The Keycloak realm under which clients resides. + default: 'master' + + client_id: + type: str + required: true + description: + - Roles provided in O(role_names) while be added to this client scope. + + client_scope_id: + type: str + description: + - If the O(role_names) are client role, the client ID under which it resides. + - If this parameter is absent, the roles are considered a realm role. + role_names: + required: true + type: list + elements: str + description: + - Names of roles to manipulate. + - If O(client_scope_id) is present, all roles must be under this client. + - If O(client_scope_id) is absent, all roles must be under the realm. + + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Andre Desrosiers (@desand01) +''' + +EXAMPLES = ''' +- name: Add roles to public client scope + community.general.keycloak_client_rolescope: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + client_scope_id: backend-client-private + role_names: + - backend-role-admin + - backend-role-user + +- name: Remove roles from public client scope + community.general.keycloak_client_rolescope: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + client_scope_id: backend-client-private + role_names: + - backend-role-admin + state: absent + +- name: Add realm roles to public client scope + community.general.keycloak_client_rolescope: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + role_names: + - realm-role-admin + - realm-role-user +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client role scope for frontend-client-public has been updated" + +end_state: + description: Representation of role role scope after module execution. + returned: on success + type: list + elements: dict + sample: [ + { + "clientRole": false, + "composite": false, + "containerId": "MyCustomRealm", + "id": "47293104-59a6-46f0-b460-2e9e3c9c424c", + "name": "backend-role-admin" + }, + { + "clientRole": false, + "composite": false, + "containerId": "MyCustomRealm", + "id": "39c62a6d-542c-4715-92d2-41021eb33967", + "name": "backend-role-user" + } + ] +''' + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + client_id=dict(type='str', required=True), + client_scope_id=dict(type='str'), + realm=dict(type='str', default='master'), + role_names=dict(type='list', elements='str', required=True), + state=dict(type='str', default='present', choices=['present', 'absent']), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + result = dict(changed=False, msg='', diff={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + clientid = module.params.get('client_id') + client_scope_id = module.params.get('client_scope_id') + role_names = module.params.get('role_names') + state = module.params.get('state') + + objRealm = kc.get_realm_by_id(realm) + if not objRealm: + module.fail_json(msg="Failed to retrive realm '{realm}'".format(realm=realm)) + + objClient = kc.get_client_by_clientid(clientid, realm) + if not objClient: + module.fail_json(msg="Failed to retrive client '{realm}.{clientid}'".format(realm=realm, clientid=clientid)) + if objClient["fullScopeAllowed"] and state == "present": + module.fail_json(msg="FullScopeAllowed is active for Client '{realm}.{clientid}'".format(realm=realm, clientid=clientid)) + + if client_scope_id: + objClientScope = kc.get_client_by_clientid(client_scope_id, realm) + if not objClientScope: + module.fail_json(msg="Failed to retrive client '{realm}.{client_scope_id}'".format(realm=realm, client_scope_id=client_scope_id)) + before_role_mapping = kc.get_client_role_scope_from_client(objClient["id"], objClientScope["id"], realm) + else: + before_role_mapping = kc.get_client_role_scope_from_realm(objClient["id"], realm) + + if client_scope_id: + # retrive all role from client_scope + client_scope_roles_by_name = kc.get_client_roles_by_id(objClientScope["id"], realm) + else: + # retrive all role from realm + client_scope_roles_by_name = kc.get_realm_roles(realm) + + # convert to indexed Dict by name + client_scope_roles_by_name = {role["name"]: role for role in client_scope_roles_by_name} + role_mapping_by_name = {role["name"]: role for role in before_role_mapping} + role_mapping_to_manipulate = [] + + if state == "present": + # update desired + for role_name in role_names: + if role_name not in client_scope_roles_by_name: + if client_scope_id: + module.fail_json(msg="Failed to retrive role '{realm}.{client_scope_id}.{role_name}'" + .format(realm=realm, client_scope_id=client_scope_id, role_name=role_name)) + else: + module.fail_json(msg="Failed to retrive role '{realm}.{role_name}'".format(realm=realm, role_name=role_name)) + if role_name not in role_mapping_by_name: + role_mapping_to_manipulate.append(client_scope_roles_by_name[role_name]) + role_mapping_by_name[role_name] = client_scope_roles_by_name[role_name] + else: + # remove role if present + for role_name in role_names: + if role_name in role_mapping_by_name: + role_mapping_to_manipulate.append(role_mapping_by_name[role_name]) + del role_mapping_by_name[role_name] + + before_role_mapping = sorted(before_role_mapping, key=lambda d: d['name']) + desired_role_mapping = sorted(role_mapping_by_name.values(), key=lambda d: d['name']) + + result['changed'] = len(role_mapping_to_manipulate) > 0 + + if result['changed']: + result['diff'] = dict(before=before_role_mapping, after=desired_role_mapping) + + if not result['changed']: + # no changes + result['end_state'] = before_role_mapping + result['msg'] = "No changes required for client role scope {name}.".format(name=clientid) + elif state == "present": + # doing update + if module.check_mode: + result['end_state'] = desired_role_mapping + elif client_scope_id: + result['end_state'] = kc.update_client_role_scope_from_client(role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm) + else: + result['end_state'] = kc.update_client_role_scope_from_realm(role_mapping_to_manipulate, objClient["id"], realm) + result['msg'] = "Client role scope for {name} has been updated".format(name=clientid) + else: + # doing delete + if module.check_mode: + result['end_state'] = desired_role_mapping + elif client_scope_id: + result['end_state'] = kc.delete_client_role_scope_from_client(role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm) + else: + result['end_state'] = kc.delete_client_role_scope_from_realm(role_mapping_to_manipulate, objClient["id"], realm) + result['msg'] = "Client role scope for {name} has been deleted".format(name=clientid) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_clientscope.py b/plugins/modules/keycloak_clientscope.py index d37af5f0cf..576a831bdb 100644 --- a/plugins/modules/keycloak_clientscope.py +++ b/plugins/modules/keycloak_clientscope.py @@ -79,7 +79,8 @@ options: protocol: description: - Type of client. - choices: ['openid-connect', 'saml', 'wsfed'] + - The V(docker-v2) value was added in community.general 8.6.0. + choices: ['openid-connect', 'saml', 'wsfed', 'docker-v2'] type: str protocol_mappers: @@ -95,7 +96,7 @@ options: description: - This specifies for which protocol this protocol mapper. - is active. - choices: ['openid-connect', 'saml', 'wsfed'] + choices: ['openid-connect', 'saml', 'wsfed', 'docker-v2'] type: str protocolMapper: @@ -300,10 +301,37 @@ end_state: ''' from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError + keycloak_argument_spec, get_token, KeycloakError, is_struct_included from ansible.module_utils.basic import AnsibleModule +def normalise_cr(clientscoperep, remove_ids=False): + """ Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the + the change detection is more effective. + + :param clientscoperep: the clientscoperep dict to be sanitized + :param remove_ids: If set to true, then the unique ID's of objects is removed to make the diff and checks for changed + not alert when the ID's of objects are not usually known, (e.g. for protocol_mappers) + :return: normalised clientscoperep dict + """ + # Avoid the dict passed in to be modified + clientscoperep = clientscoperep.copy() + + if 'attributes' in clientscoperep: + clientscoperep['attributes'] = list(sorted(clientscoperep['attributes'])) + + if 'protocolMappers' in clientscoperep: + clientscoperep['protocolMappers'] = sorted(clientscoperep['protocolMappers'], key=lambda x: (x.get('name'), x.get('protocol'), x.get('protocolMapper'))) + for mapper in clientscoperep['protocolMappers']: + if remove_ids: + mapper.pop('id', None) + + # Set to a default value. + mapper['consentRequired'] = mapper.get('consentRequired', False) + + return clientscoperep + + def sanitize_cr(clientscoperep): """ Removes probably sensitive details from a clientscoperep representation. @@ -316,7 +344,7 @@ def sanitize_cr(clientscoperep): if 'attributes' in result: if 'saml.signing.private.key' in result['attributes']: result['attributes']['saml.signing.private.key'] = 'no_log' - return result + return normalise_cr(result) def main(): @@ -330,7 +358,7 @@ def main(): protmapper_spec = dict( id=dict(type='str'), name=dict(type='str'), - protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed']), + protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed', 'docker-v2']), protocolMapper=dict(type='str'), config=dict(type='dict'), ) @@ -341,7 +369,7 @@ def main(): id=dict(type='str'), name=dict(type='str'), description=dict(type='str'), - protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed']), + protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed', 'docker-v2']), attributes=dict(type='dict'), protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), ) @@ -400,7 +428,7 @@ def main(): # Unfortunately, the ansible argument spec checker introduces variables with null values when # they are not specified if clientscope_param == 'protocol_mappers': - new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] changeset[camel(clientscope_param)] = new_param_value # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) @@ -444,7 +472,9 @@ def main(): # Process an update # no changes - if desired_clientscope == before_clientscope: + # remove ids for compare, problematic if desired has no ids set (not required), + # normalize for consentRequired in protocolMappers + if normalise_cr(desired_clientscope, remove_ids=True) == normalise_cr(before_clientscope, remove_ids=True): result['changed'] = False result['end_state'] = sanitize_cr(desired_clientscope) result['msg'] = "No changes required to clientscope {name}.".format(name=before_clientscope['name']) @@ -457,6 +487,13 @@ def main(): result['diff'] = dict(before=sanitize_cr(before_clientscope), after=sanitize_cr(desired_clientscope)) if module.check_mode: + # We can only compare the current clientscope with the proposed updates we have + before_norm = normalise_cr(before_clientscope, remove_ids=True) + desired_norm = normalise_cr(desired_clientscope, remove_ids=True) + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_norm), + after=sanitize_cr(desired_norm)) + result['changed'] = not is_struct_included(desired_norm, before_norm) module.exit_json(**result) # do the update diff --git a/plugins/modules/keycloak_clienttemplate.py b/plugins/modules/keycloak_clienttemplate.py index cd7f6c09b7..7bffb5cbb6 100644 --- a/plugins/modules/keycloak_clienttemplate.py +++ b/plugins/modules/keycloak_clienttemplate.py @@ -68,7 +68,8 @@ options: protocol: description: - Type of client template. - choices: ['openid-connect', 'saml'] + - The V(docker-v2) value was added in community.general 8.6.0. + choices: ['openid-connect', 'saml', 'docker-v2'] type: str full_scope_allowed: @@ -107,7 +108,7 @@ options: protocol: description: - This specifies for which protocol this protocol mapper is active. - choices: ['openid-connect', 'saml'] + choices: ['openid-connect', 'saml', 'docker-v2'] type: str protocolMapper: @@ -292,7 +293,7 @@ def main(): consentText=dict(type='str'), id=dict(type='str'), name=dict(type='str'), - protocol=dict(type='str', choices=['openid-connect', 'saml']), + protocol=dict(type='str', choices=['openid-connect', 'saml', 'docker-v2']), protocolMapper=dict(type='str'), config=dict(type='dict'), ) @@ -304,7 +305,7 @@ def main(): id=dict(type='str'), name=dict(type='str'), description=dict(type='str'), - protocol=dict(type='str', choices=['openid-connect', 'saml']), + protocol=dict(type='str', choices=['openid-connect', 'saml', 'docker-v2']), attributes=dict(type='dict'), full_scope_allowed=dict(type='bool'), protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec), diff --git a/plugins/modules/keycloak_identity_provider.py b/plugins/modules/keycloak_identity_provider.py index 588f553e8d..609673653b 100644 --- a/plugins/modules/keycloak_identity_provider.py +++ b/plugins/modules/keycloak_identity_provider.py @@ -437,7 +437,7 @@ def sanitize(idp): idpcopy = deepcopy(idp) if 'config' in idpcopy: if 'clientSecret' in idpcopy['config']: - idpcopy['clientSecret'] = '**********' + idpcopy['config']['clientSecret'] = '**********' return idpcopy @@ -445,6 +445,15 @@ def get_identity_provider_with_mappers(kc, alias, realm): idp = kc.get_identity_provider(alias, realm) if idp is not None: idp['mappers'] = sorted(kc.get_identity_provider_mappers(alias, realm), key=lambda x: x.get('name')) + # clientSecret returned by API when using `get_identity_provider(alias, realm)` is always ********** + # to detect changes to the secret, we get the actual cleartext secret from the full realm info + if 'config' in idp: + if 'clientSecret' in idp['config']: + for idp_from_realm in kc.get_realm_by_id(realm).get('identityProviders', []): + if idp_from_realm['internalId'] == idp['internalId']: + cleartext_secret = idp_from_realm.get('config', {}).get('clientSecret') + if cleartext_secret: + idp['config']['clientSecret'] = cleartext_secret if idp is None: idp = {} return idp @@ -525,7 +534,7 @@ def main(): # special handling of mappers list to allow change detection if module.params.get('mappers') is not None: for change in module.params['mappers']: - change = dict((k, v) for k, v in change.items() if change[k] is not None) + change = {k: v for k, v in change.items() if v is not None} if change.get('id') is None and change.get('name') is None: module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.') if before_idp == dict(): diff --git a/plugins/modules/keycloak_realm.py b/plugins/modules/keycloak_realm.py index 9f2e72b525..6128c9e4c7 100644 --- a/plugins/modules/keycloak_realm.py +++ b/plugins/modules/keycloak_realm.py @@ -582,6 +582,27 @@ from ansible_collections.community.general.plugins.module_utils.identity.keycloa from ansible.module_utils.basic import AnsibleModule +def normalise_cr(realmrep): + """ Re-sorts any properties where the order is important so that diff's is minimised and the change detection is more effective. + + :param realmrep: the realmrep dict to be sanitized + :return: normalised realmrep dict + """ + # Avoid the dict passed in to be modified + realmrep = realmrep.copy() + + if 'enabledEventTypes' in realmrep: + realmrep['enabledEventTypes'] = list(sorted(realmrep['enabledEventTypes'])) + + if 'otpSupportedApplications' in realmrep: + realmrep['otpSupportedApplications'] = list(sorted(realmrep['otpSupportedApplications'])) + + if 'supportedLocales' in realmrep: + realmrep['supportedLocales'] = list(sorted(realmrep['supportedLocales'])) + + return realmrep + + def sanitize_cr(realmrep): """ Removes probably sensitive details from a realm representation. @@ -595,7 +616,7 @@ def sanitize_cr(realmrep): if 'saml.signing.private.key' in result['attributes']: result['attributes'] = result['attributes'].copy() result['attributes']['saml.signing.private.key'] = '********' - return result + return normalise_cr(result) def main(): @@ -777,9 +798,11 @@ def main(): result['changed'] = True if module.check_mode: # We can only compare the current realm with the proposed updates we have + before_norm = normalise_cr(before_realm) + desired_norm = normalise_cr(desired_realm) if module._diff: - result['diff'] = dict(before=before_realm_sanitized, - after=sanitize_cr(desired_realm)) + result['diff'] = dict(before=sanitize_cr(before_norm), + after=sanitize_cr(desired_norm)) result['changed'] = (before_realm != desired_realm) module.exit_json(**result) diff --git a/plugins/modules/keycloak_realm_key.py b/plugins/modules/keycloak_realm_key.py index 6e762fba9d..edc8a6068e 100644 --- a/plugins/modules/keycloak_realm_key.py +++ b/plugins/modules/keycloak_realm_key.py @@ -68,7 +68,7 @@ options: type: bool parent_id: description: - - The parent_id of the realm key. In practice the ID (name) of the realm. + - The parent_id of the realm key. In practice the name of the realm. type: str required: true provider_id: @@ -300,7 +300,7 @@ def main(): kc = KeycloakAPI(module, connection_header) - params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "force"] + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "force", "parent_id"] # Filter and map the parameters names that apply to the role component_params = [x for x in module.params @@ -371,7 +371,7 @@ def main(): parent_id = module.params.get('parent_id') # Get a list of all Keycloak components that are of keyprovider type. - realm_keys = kc.get_components(urlencode(dict(type=provider_type, parent=parent_id)), parent_id) + realm_keys = kc.get_components(urlencode(dict(type=provider_type)), parent_id) # If this component is present get its key ID. Confusingly the key ID is # also known as the Provider ID. diff --git a/plugins/modules/keycloak_realm_keys_metadata_info.py b/plugins/modules/keycloak_realm_keys_metadata_info.py new file mode 100644 index 0000000000..ef4048b891 --- /dev/null +++ b/plugins/modules/keycloak_realm_keys_metadata_info.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: keycloak_realm_keys_metadata_info + +short_description: Allows obtaining Keycloak realm keys metadata via Keycloak API + +version_added: 9.3.0 + +description: + - This module allows you to get Keycloak realm keys metadata via the Keycloak REST API. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/latest/rest-api/index.html). + +options: + realm: + type: str + description: + - They Keycloak realm to fetch keys metadata. + default: 'master' + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + - community.general.attributes.info_module + +author: + - Thomas Bach (@thomasbach-dev) +""" + +EXAMPLES = """ +- name: Fetch Keys metadata + community.general.keycloak_realm_keys_metadata_info: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + delegate_to: localhost + register: keycloak_keys_metadata + +- name: Write the Keycloak keys certificate into a file + ansible.builtin.copy: + dest: /tmp/keycloak.cert + content: | + {{ keys_metadata['keycloak_keys_metadata']['keys'] + | selectattr('algorithm', 'equalto', 'RS256') + | map(attribute='certificate') + | first + }} + delegate_to: localhost +""" + +RETURN = """ +msg: + description: Message as to what action was taken. + returned: always + type: str + +keys_metadata: + description: + + - Representation of the realm keys metadata (see + U(https://www.keycloak.org/docs-api/latest/rest-api/index.html#KeysMetadataRepresentation)). + + returned: always + type: dict + contains: + active: + description: A mapping (that is, a dict) from key algorithms to UUIDs. + type: dict + returned: always + keys: + description: A list of dicts providing detailed information on the keys. + type: list + elements: dict + returned: always +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, KeycloakError, get_token, keycloak_argument_spec) + + +def main(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(default="master"), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([["token", "auth_realm", "auth_username", "auth_password"]]), + required_together=([["auth_realm", "auth_username", "auth_password"]]), + ) + + result = dict(changed=False, msg="", keys_metadata="") + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get("realm") + + keys_metadata = kc.get_realm_keys_metadata_by_id(realm=realm) + + result["keys_metadata"] = keys_metadata + result["msg"] = "Get realm keys metadata successful for ID {realm}".format( + realm=realm + ) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py index fee0d1265c..6034aa8b84 100644 --- a/plugins/modules/keycloak_user_federation.py +++ b/plugins/modules/keycloak_user_federation.py @@ -85,6 +85,14 @@ options: - parentId type: str + remove_unspecified_mappers: + description: + - Remove mappers that are not specified in the configuration for this federation. + - Set to V(false) to keep mappers that are not listed in O(mappers). + type: bool + default: true + version_added: 9.4.0 + config: description: - Dict specifying the configuration options for the provider; the contents differ depending on @@ -716,13 +724,16 @@ from copy import deepcopy def sanitize(comp): compcopy = deepcopy(comp) if 'config' in compcopy: - compcopy['config'] = dict((k, v[0]) for k, v in compcopy['config'].items()) + compcopy['config'] = {k: v[0] for k, v in compcopy['config'].items()} if 'bindCredential' in compcopy['config']: compcopy['config']['bindCredential'] = '**********' + # an empty string is valid for krbPrincipalAttribute but is filtered out in diff + if 'krbPrincipalAttribute' not in compcopy['config']: + compcopy['config']['krbPrincipalAttribute'] = '' if 'mappers' in compcopy: for mapper in compcopy['mappers']: if 'config' in mapper: - mapper['config'] = dict((k, v[0]) for k, v in mapper['config'].items()) + mapper['config'] = {k: v[0] for k, v in mapper['config'].items()} return compcopy @@ -805,6 +816,7 @@ def main(): provider_id=dict(type='str', aliases=['providerId']), provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'), parent_id=dict(type='str', aliases=['parentId']), + remove_unspecified_mappers=dict(type='bool', default=True), mappers=dict(type='list', elements='dict', options=mapper_spec), ) @@ -835,18 +847,24 @@ def main(): # Keycloak API expects config parameters to be arrays containing a single string element if config is not None: - module.params['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v]) - for k, v in config.items() if config[k] is not None) + module.params['config'] = { + k: [str(v).lower() if not isinstance(v, str) else v] + for k, v in config.items() + if config[k] is not None + } if mappers is not None: for mapper in mappers: if mapper.get('config') is not None: - mapper['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v]) - for k, v in mapper['config'].items() if mapper['config'][k] is not None) + mapper['config'] = { + k: [str(v).lower() if not isinstance(v, str) else v] + for k, v in mapper['config'].items() + if mapper['config'][k] is not None + } # Filter and map the parameters names that apply comp_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'mappers'] and + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'mappers', 'remove_unspecified_mappers'] and module.params.get(x) is not None] # See if it already exists in Keycloak @@ -865,7 +883,7 @@ def main(): # if user federation exists, get associated mappers if cid is not None and before_comp: - before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name')) + before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') # Build a proposed changeset from parameters given to this module changeset = {} @@ -874,7 +892,7 @@ def main(): new_param_value = module.params.get(param) old_value = before_comp[camel(param)] if camel(param) in before_comp else None if param == 'mappers': - new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] if new_param_value != old_value: changeset[camel(param)] = new_param_value @@ -883,17 +901,17 @@ def main(): if module.params['provider_id'] in ['kerberos', 'sssd']: module.fail_json(msg='Cannot configure mappers for {type} provider.'.format(type=module.params['provider_id'])) for change in module.params['mappers']: - change = dict((k, v) for k, v in change.items() if change[k] is not None) + change = {k: v for k, v in change.items() if v is not None} if change.get('id') is None and change.get('name') is None: module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.') if cid is None: old_mapper = {} elif change.get('id') is not None: - old_mapper = kc.get_component(change['id'], realm) + old_mapper = next((before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper["id"] == change['id']), None) if old_mapper is None: old_mapper = {} else: - found = kc.get_components(urlencode(dict(parent=cid, name=change['name'])), realm) + found = [before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper['name'] == change['name']] if len(found) > 1: module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=change['name'])) if len(found) == 1: @@ -902,10 +920,16 @@ def main(): old_mapper = {} new_mapper = old_mapper.copy() new_mapper.update(change) - if new_mapper != old_mapper: - if changeset.get('mappers') is None: - changeset['mappers'] = list() - changeset['mappers'].append(new_mapper) + # changeset contains all desired mappers: those existing, to update or to create + if changeset.get('mappers') is None: + changeset['mappers'] = list() + changeset['mappers'].append(new_mapper) + changeset['mappers'] = sorted(changeset['mappers'], key=lambda x: x.get('name') or '') + + # to keep unspecified existing mappers we add them to the desired mappers list, unless they're already present + if not module.params['remove_unspecified_mappers'] and 'mappers' in before_comp: + changeset_mapper_ids = [mapper['id'] for mapper in changeset['mappers'] if 'id' in mapper] + changeset['mappers'].extend([mapper for mapper in before_comp['mappers'] if mapper['id'] not in changeset_mapper_ids]) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) desired_comp = before_comp.copy() @@ -928,42 +952,52 @@ def main(): # Process a creation result['changed'] = True - if module._diff: - result['diff'] = dict(before='', after=sanitize(desired_comp)) - if module.check_mode: + if module._diff: + result['diff'] = dict(before='', after=sanitize(desired_comp)) module.exit_json(**result) # create it - desired_comp = desired_comp.copy() - updated_mappers = desired_comp.pop('mappers', []) + desired_mappers = desired_comp.pop('mappers', []) after_comp = kc.create_component(desired_comp, realm) - cid = after_comp['id'] + updated_mappers = [] + # when creating a user federation, keycloak automatically creates default mappers + default_mappers = kc.get_components(urlencode(dict(parent=cid)), realm) - for mapper in updated_mappers: - found = kc.get_components(urlencode(dict(parent=cid, name=mapper['name'])), realm) + # create new mappers or update existing default mappers + for desired_mapper in desired_mappers: + found = [default_mapper for default_mapper in default_mappers if default_mapper['name'] == desired_mapper['name']] if len(found) > 1: - module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=mapper['name'])) + module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=desired_mapper['name'])) if len(found) == 1: old_mapper = found[0] else: old_mapper = {} new_mapper = old_mapper.copy() - new_mapper.update(mapper) + new_mapper.update(desired_mapper) if new_mapper.get('id') is not None: kc.update_component(new_mapper, realm) + updated_mappers.append(new_mapper) else: if new_mapper.get('parentId') is None: - new_mapper['parentId'] = after_comp['id'] - mapper = kc.create_component(new_mapper, realm) + new_mapper['parentId'] = cid + updated_mappers.append(kc.create_component(new_mapper, realm)) - after_comp['mappers'] = updated_mappers + if module.params['remove_unspecified_mappers']: + # we remove all unwanted default mappers + # we use ids so we dont accidently remove one of the previously updated default mapper + for default_mapper in default_mappers: + if not default_mapper['id'] in [x['id'] for x in updated_mappers]: + kc.delete_component(default_mapper['id'], realm) + + after_comp['mappers'] = kc.get_components(urlencode(dict(parent=cid)), realm) + if module._diff: + result['diff'] = dict(before='', after=sanitize(after_comp)) result['end_state'] = sanitize(after_comp) - - result['msg'] = "User federation {id} has been created".format(id=after_comp['id']) + result['msg'] = "User federation {id} has been created".format(id=cid) module.exit_json(**result) else: @@ -987,22 +1021,32 @@ def main(): module.exit_json(**result) # do the update - desired_comp = desired_comp.copy() - updated_mappers = desired_comp.pop('mappers', []) + desired_mappers = desired_comp.pop('mappers', []) kc.update_component(desired_comp, realm) - after_comp = kc.get_component(cid, realm) - for mapper in updated_mappers: + for before_mapper in before_comp.get('mappers', []): + # remove unwanted existing mappers that will not be updated + if not before_mapper['id'] in [x['id'] for x in desired_mappers if 'id' in x]: + kc.delete_component(before_mapper['id'], realm) + + for mapper in desired_mappers: + if mapper in before_comp.get('mappers', []): + continue if mapper.get('id') is not None: kc.update_component(mapper, realm) else: if mapper.get('parentId') is None: mapper['parentId'] = desired_comp['id'] - mapper = kc.create_component(mapper, realm) - - after_comp['mappers'] = updated_mappers - result['end_state'] = sanitize(after_comp) + kc.create_component(mapper, realm) + after_comp = kc.get_component(cid, realm) + after_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') + after_comp_sanitized = sanitize(after_comp) + before_comp_sanitized = sanitize(before_comp) + result['end_state'] = after_comp_sanitized + if module._diff: + result['diff'] = dict(before=before_comp_sanitized, after=after_comp_sanitized) + result['changed'] = before_comp_sanitized != after_comp_sanitized result['msg'] = "User federation {id} has been updated".format(id=cid) module.exit_json(**result) diff --git a/plugins/modules/keycloak_userprofile.py b/plugins/modules/keycloak_userprofile.py new file mode 100644 index 0000000000..ba5dc127d2 --- /dev/null +++ b/plugins/modules/keycloak_userprofile.py @@ -0,0 +1,732 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_userprofile + +short_description: Allows managing Keycloak User Profiles + +description: + - This module allows you to create, update, or delete Keycloak User Profiles via Keycloak API. You can also customize the "Unmanaged Attributes" with it. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/24.0.5/rest-api/index.html). + For compatibility reasons, the module also accepts the camelCase versions of the options. + +version_added: "9.4.0" + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the User Profile provider. + - On V(present), the User Profile provider will be created if it does not yet exist, or updated with + the parameters you provide. + - On V(absent), the User Profile provider will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + parent_id: + description: + - The parent ID of the realm key. In practice the ID (name) of the realm. + aliases: + - parentId + - realm + type: str + required: true + + provider_id: + description: + - The name of the provider ID for the key (supported value is V(declarative-user-profile)). + aliases: + - providerId + choices: ['declarative-user-profile'] + default: 'declarative-user-profile' + type: str + + provider_type: + description: + - Component type for User Profile (only supported value is V(org.keycloak.userprofile.UserProfileProvider)). + aliases: + - providerType + choices: ['org.keycloak.userprofile.UserProfileProvider'] + default: org.keycloak.userprofile.UserProfileProvider + type: str + + config: + description: + - The configuration of the User Profile Provider. + type: dict + required: false + suboptions: + kc_user_profile_config: + description: + - Define a declarative User Profile. See EXAMPLES for more context. + aliases: + - kcUserProfileConfig + type: list + elements: dict + suboptions: + attributes: + description: + - A list of attributes to be included in the User Profile. + type: list + elements: dict + suboptions: + name: + description: + - The name of the attribute. + type: str + required: true + + display_name: + description: + - The display name of the attribute. + aliases: + - displayName + type: str + required: true + + validations: + description: + - The validations to be applied to the attribute. + type: dict + suboptions: + length: + description: + - The length validation for the attribute. + type: dict + suboptions: + min: + description: + - The minimum length of the attribute. + type: int + max: + description: + - The maximum length of the attribute. + type: int + required: true + + email: + description: + - The email validation for the attribute. + type: dict + + username_prohibited_characters: + description: + - The prohibited characters validation for the username attribute. + type: dict + aliases: + - usernameProhibitedCharacters + + up_username_not_idn_homograph: + description: + - The validation to prevent IDN homograph attacks in usernames. + type: dict + aliases: + - upUsernameNotIdnHomograph + + person_name_prohibited_characters: + description: + - The prohibited characters validation for person name attributes. + type: dict + aliases: + - personNameProhibitedCharacters + + uri: + description: + - The URI validation for the attribute. + type: dict + + pattern: + description: + - The pattern validation for the attribute using regular expressions. + type: dict + + options: + description: + - Validation to ensure the attribute matches one of the provided options. + type: dict + + annotations: + description: + - Annotations for the attribute. + type: dict + + group: + description: + - Specifies the User Profile group where this attribute will be added. + type: str + + permissions: + description: + - The permissions for viewing and editing the attribute. + type: dict + suboptions: + view: + description: + - The roles that can view the attribute. + - Supported values are V(admin) and V(user). + type: list + elements: str + default: + - admin + - user + + edit: + description: + - The roles that can edit the attribute. + - Supported values are V(admin) and V(user). + type: list + elements: str + default: + - admin + - user + + multivalued: + description: + - Whether the attribute can have multiple values. + type: bool + default: false + + required: + description: + - The roles that require this attribute. + type: dict + suboptions: + roles: + description: + - The roles for which this attribute is required. + - Supported values are V(admin) and V(user). + type: list + elements: str + default: + - user + + groups: + description: + - A list of attribute groups to be included in the User Profile. + type: list + elements: dict + suboptions: + name: + description: + - The name of the group. + type: str + required: true + + display_header: + description: + - The display header for the group. + aliases: + - displayHeader + type: str + required: true + + display_description: + description: + - The display description for the group. + aliases: + - displayDescription + type: str + required: false + + annotations: + description: + - The annotations included in the group. + type: dict + required: false + + unmanaged_attribute_policy: + description: + - Policy for unmanaged attributes. + aliases: + - unmanagedAttributePolicy + type: str + choices: + - ENABLED + - ADMIN_EDIT + - ADMIN_VIEW + +notes: + - Currently, only a single V(declarative-user-profile) entry is supported for O(provider_id) (design of the Keyckoak API). + However, there can be multiple O(config.kc_user_profile_config[].attributes[]) entries. + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Eike Waldt (@yeoldegrove) +''' + +EXAMPLES = ''' +- name: Create a Declarative User Profile with default settings + community.general.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - attributes: + - name: username + displayName: ${username} + validations: + length: + min: 3 + max: 255 + username_prohibited_characters: {} + up_username_not_idn_homograph: {} + annotations: {} + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: email + displayName: ${email} + validations: + email: {} + length: + max: 255 + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: firstName + displayName: ${firstName} + validations: + length: + max: 255 + person_name_prohibited_characters: {} + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: lastName + displayName: ${lastName} + validations: + length: + max: 255 + person_name_prohibited_characters: {} + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + groups: + - name: user-metadata + displayHeader: User metadata + displayDescription: Attributes, which refer to user metadata + annotations: {} + +- name: Delete a Keycloak User Profile Provider + keycloak_userprofile: + state: absent + parent_id: master + +# Unmanaged attributes are user attributes not explicitly defined in the User Profile +# configuration. By default, unmanaged attributes are "Disabled" and are not +# available from any context such as registration, account, and the +# administration console. By setting "Enabled", unmanaged attributes are fully +# recognized by the server and accessible through all contexts, useful if you are +# starting migrating an existing realm to the declarative User Profile +# and you don't have yet all user attributes defined in the User Profile configuration. +- name: Enable Unmanaged Attributes + community.general.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ENABLED + +# By setting "Only administrators can write", unmanaged attributes can be managed +# only through the administration console and API, useful if you have already +# defined any custom attribute that can be managed by users but you are unsure +# about adding other attributes that should only be managed by administrators. +- name: Enable ADMIN_EDIT on Unmanaged Attributes + community.general.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ADMIN_EDIT + +# By setting `Only administrators can view`, unmanaged attributes are read-only +# and only available through the administration console and API. +- name: Enable ADMIN_VIEW on Unmanaged Attributes + community.general.keycloak_userprofile: + state: present + parent_id: master + config: + kc_user_profile_config: + - unmanagedAttributePolicy: ADMIN_VIEW +''' + +RETURN = ''' +msg: + description: The output message generated by the module. + returned: always + type: str + sample: UserProfileProvider created successfully +data: + description: The data returned by the Keycloak API. + returned: when state is present + type: dict + sample: {...} +''' + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode +from copy import deepcopy +import json + + +def remove_null_values(data): + if isinstance(data, dict): + # Recursively remove null values from dictionaries + return {k: remove_null_values(v) for k, v in data.items() if v is not None} + elif isinstance(data, list): + # Recursively remove null values from lists + return [remove_null_values(item) for item in data if item is not None] + else: + # Return the data if it's neither a dictionary nor a list + return data + + +def camel_recursive(data): + if isinstance(data, dict): + # Convert keys to camelCase and apply recursively + return {camel(k): camel_recursive(v) for k, v in data.items()} + elif isinstance(data, list): + # Apply camelCase conversion to each item in the list + return [camel_recursive(item) for item in data] + else: + # Return the data as is if it's not a dict or list + return data + + +def main(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type='str', choices=['present', 'absent'], default='present'), + parent_id=dict(type='str', aliases=['parentId', 'realm'], required=True), + provider_id=dict(type='str', aliases=['providerId'], default='declarative-user-profile', choices=['declarative-user-profile']), + provider_type=dict( + type='str', + aliases=['providerType'], + default='org.keycloak.userprofile.UserProfileProvider', + choices=['org.keycloak.userprofile.UserProfileProvider'] + ), + config=dict( + type='dict', + required=False, + options={ + 'kc_user_profile_config': dict( + type='list', + aliases=['kcUserProfileConfig'], + elements='dict', + options={ + 'attributes': dict( + type='list', + elements='dict', + required=False, + options={ + 'name': dict(type='str', required=True), + 'display_name': dict(type='str', aliases=['displayName'], required=True), + 'validations': dict( + type='dict', + options={ + 'length': dict( + type='dict', + options={ + 'min': dict(type='int', required=False), + 'max': dict(type='int', required=True) + } + ), + 'email': dict(type='dict', required=False), + 'username_prohibited_characters': dict(type='dict', aliases=['usernameProhibitedCharacters'], required=False), + 'up_username_not_idn_homograph': dict(type='dict', aliases=['upUsernameNotIdnHomograph'], required=False), + 'person_name_prohibited_characters': dict(type='dict', aliases=['personNameProhibitedCharacters'], required=False), + 'uri': dict(type='dict', required=False), + 'pattern': dict(type='dict', required=False), + 'options': dict(type='dict', required=False) + } + ), + 'annotations': dict(type='dict'), + 'group': dict(type='str'), + 'permissions': dict( + type='dict', + options={ + 'view': dict(type='list', elements='str', default=['admin', 'user']), + 'edit': dict(type='list', elements='str', default=['admin', 'user']) + } + ), + 'multivalued': dict(type='bool', default=False), + 'required': dict( + type='dict', + options={ + 'roles': dict(type='list', elements='str', default=['user']) + } + ) + } + ), + 'groups': dict( + type='list', + elements='dict', + options={ + 'name': dict(type='str', required=True), + 'display_header': dict(type='str', aliases=['displayHeader'], required=True), + 'display_description': dict(type='str', aliases=['displayDescription'], required=False), + 'annotations': dict(type='dict', required=False) + } + ), + 'unmanaged_attribute_policy': dict( + type='str', + aliases=['unmanagedAttributePolicy'], + choices=['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'], + required=False + ) + } + ) + } + ) + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + # Initialize the result object. Only "changed" seems to have special + # meaning for Ansible. + result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) + + # This will include the current state of the realm userprofile if it is already + # present. This is only used for diff-mode. + before_realm_userprofile = {} + before_realm_userprofile['config'] = {} + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + params_to_ignore = list(keycloak_argument_spec().keys()) + ["state"] + + # Filter and map the parameters names that apply to the role + component_params = [ + x + for x in module.params + if x not in params_to_ignore and module.params.get(x) is not None + ] + + # Build a proposed changeset from parameters given to this module + changeset = {} + + # Build the changeset with proper JSON serialization for kc_user_profile_config + config = module.params.get('config') + changeset['config'] = {} + + # Generate a JSON payload for Keycloak Admin API from the module + # parameters. Parameters that do not belong to the JSON payload (e.g. + # "state" or "auth_keycloal_url") have been filtered away earlier (see + # above). + # + # This loop converts Ansible module parameters (snake-case) into + # Keycloak-compatible format (camel-case). For example proider_id + # becomes providerId. It also handles some special cases, e.g. aliases. + for component_param in component_params: + # realm/parent_id parameter + if component_param == 'realm' or component_param == 'parent_id': + changeset['parent_id'] = module.params.get(component_param) + changeset.pop(component_param, None) + # complex parameters in config suboptions + elif component_param == 'config': + for config_param in config: + # special parameter kc_user_profile_config + if config_param in ('kcUserProfileConfig', 'kc_user_profile_config'): + config_param_org = config_param + # rename parameter to be accepted by Keycloak API + config_param = 'kc.user.profile.config' + # make sure no null values are passed to Keycloak API + kc_user_profile_config = remove_null_values(config[config_param_org]) + changeset[camel(component_param)][config_param] = [] + if len(kc_user_profile_config) > 0: + # convert aliases to camelCase + kc_user_profile_config = camel_recursive(kc_user_profile_config) + # rename validations to be accepted by Keycloak API + if 'attributes' in kc_user_profile_config[0]: + for attribute in kc_user_profile_config[0]['attributes']: + if 'validations' in attribute: + if 'usernameProhibitedCharacters' in attribute['validations']: + attribute['validations']['username-prohibited-characters'] = ( + attribute['validations'].pop('usernameProhibitedCharacters') + ) + if 'upUsernameNotIdnHomograph' in attribute['validations']: + attribute['validations']['up-username-not-idn-homograph'] = ( + attribute['validations'].pop('upUsernameNotIdnHomograph') + ) + if 'personNameProhibitedCharacters' in attribute['validations']: + attribute['validations']['person-name-prohibited-characters'] = ( + attribute['validations'].pop('personNameProhibitedCharacters') + ) + # special JSON parsing for kc_user_profile_config + value = json.dumps(kc_user_profile_config[0]) + changeset[camel(component_param)][config_param].append(value) + # usual camelCase parameters + else: + changeset[camel(component_param)][camel(config_param)] = [] + raw_value = module.params.get(component_param)[config_param] + if isinstance(raw_value, bool): + value = str(raw_value).lower() + else: + value = raw_value # Directly use the raw value + changeset[camel(component_param)][camel(config_param)].append(value) + # usual parameters + else: + new_param_value = module.params.get(component_param) + changeset[camel(component_param)] = new_param_value + + # Make it easier to refer to current module parameters + state = module.params.get('state') + enabled = module.params.get('enabled') + parent_id = module.params.get('parent_id') + provider_type = module.params.get('provider_type') + provider_id = module.params.get('provider_id') + + # Make a deep copy of the changeset. This is use when determining + # changes to the current state. + changeset_copy = deepcopy(changeset) + + # Get a list of all Keycloak components that are of userprofile provider type. + realm_userprofiles = kc.get_components(urlencode(dict(type=provider_type, parent=parent_id)), parent_id) + + # If this component is present get its userprofile ID. Confusingly the userprofile ID is + # also known as the Provider ID. + userprofile_id = None + + # Track individual parameter changes + changes = "" + + # This tells Ansible whether the userprofile was changed (added, removed, modified) + result['changed'] = False + + # Loop through the list of components. If we encounter a component whose + # name matches the value of the name parameter then assume the userprofile is + # already present. + for userprofile in realm_userprofiles: + if provider_id == "declarative-user-profile": + userprofile_id = userprofile['id'] + changeset['id'] = userprofile_id + changeset_copy['id'] = userprofile_id + + # Compare top-level parameters + for param, value in changeset.items(): + before_realm_userprofile[param] = userprofile[param] + + if changeset_copy[param] != userprofile[param] and param != 'config': + changes += "%s: %s -> %s, " % (param, userprofile[param], changeset_copy[param]) + result['changed'] = True + + # Compare parameters under the "config" userprofile + for p, v in changeset_copy['config'].items(): + before_realm_userprofile['config'][p] = userprofile['config'][p] + if changeset_copy['config'][p] != userprofile['config'][p]: + changes += "config.%s: %s -> %s, " % (p, userprofile['config'][p], changeset_copy['config'][p]) + result['changed'] = True + + # Check all the possible states of the resource and do what is needed to + # converge current state with desired state (create, update or delete + # the userprofile). + if userprofile_id and state == 'present': + if result['changed']: + if module._diff: + result['diff'] = dict(before=before_realm_userprofile, after=changeset_copy) + + if module.check_mode: + result['msg'] = "Userprofile %s would be changed: %s" % (provider_id, changes.strip(", ")) + else: + kc.update_component(changeset, parent_id) + result['msg'] = "Userprofile %s changed: %s" % (provider_id, changes.strip(", ")) + else: + result['msg'] = "Userprofile %s was in sync" % (provider_id) + + result['end_state'] = changeset_copy + elif userprofile_id and state == 'absent': + if module._diff: + result['diff'] = dict(before=before_realm_userprofile, after={}) + + if module.check_mode: + result['changed'] = True + result['msg'] = "Userprofile %s would be deleted" % (provider_id) + else: + kc.delete_component(userprofile_id, parent_id) + result['changed'] = True + result['msg'] = "Userprofile %s deleted" % (provider_id) + + result['end_state'] = {} + elif not userprofile_id and state == 'present': + if module._diff: + result['diff'] = dict(before={}, after=changeset_copy) + + if module.check_mode: + result['changed'] = True + result['msg'] = "Userprofile %s would be created" % (provider_id) + else: + kc.create_component(changeset, parent_id) + result['changed'] = True + result['msg'] = "Userprofile %s created" % (provider_id) + + result['end_state'] = changeset_copy + elif not userprofile_id and state == 'absent': + result['changed'] = False + result['msg'] = "Userprofile %s not present" % (provider_id) + result['end_state'] = {} + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/launchd.py b/plugins/modules/launchd.py index e5942ea7cf..a6427bdb2f 100644 --- a/plugins/modules/launchd.py +++ b/plugins/modules/launchd.py @@ -514,7 +514,8 @@ def main(): result['status']['current_pid'] != result['status']['previous_pid']): result['changed'] = True if module.check_mode: - result['changed'] = True + if result['status']['current_state'] != action: + result['changed'] = True module.exit_json(**result) diff --git a/plugins/modules/ldap_search.py b/plugins/modules/ldap_search.py index 45744e634a..7958f86e0b 100644 --- a/plugins/modules/ldap_search.py +++ b/plugins/modules/ldap_search.py @@ -44,6 +44,8 @@ options: type: str description: - The LDAP scope to use. + - V(subordinate) requires the LDAPv3 subordinate feature extension. + - V(children) is equivalent to a "subtree" scope. filter: default: '(objectClass=*)' type: str diff --git a/plugins/modules/linode.py b/plugins/modules/linode.py index 9e04ac63da..9b0dabdff2 100644 --- a/plugins/modules/linode.py +++ b/plugins/modules/linode.py @@ -670,7 +670,7 @@ def main(): backupwindow=backupwindow, ) - kwargs = dict((k, v) for k, v in check_items.items() if v is not None) + kwargs = {k: v for k, v in check_items.items() if v is not None} # setup the auth try: diff --git a/plugins/modules/locale_gen.py b/plugins/modules/locale_gen.py index 0dd76c9ab4..8886cdc9cd 100644 --- a/plugins/modules/locale_gen.py +++ b/plugins/modules/locale_gen.py @@ -25,9 +25,11 @@ attributes: support: none options: name: - type: str + type: list + elements: str description: - - Name and encoding of the locale, such as "en_GB.UTF-8". + - Name and encoding of the locales, such as V(en_GB.UTF-8). + - Before community.general 9.3.0, this was a string. Using a string still works. required: true state: type: str @@ -44,6 +46,13 @@ EXAMPLES = ''' community.general.locale_gen: name: de_CH.UTF-8 state: present + +- name: Ensure multiple locales exist + community.general.locale_gen: + name: + - en_GB.UTF-8 + - nl_NL.UTF-8 + state: present ''' import os @@ -74,11 +83,12 @@ class LocaleGen(StateModuleHelper): output_params = ["name"] module = dict( argument_spec=dict( - name=dict(type='str', required=True), + name=dict(type="list", elements="str", required=True), state=dict(type='str', default='present', choices=['absent', 'present']), ), supports_check_mode=True, ) + use_old_vardict = False def __init_module__(self): self.vars.set("ubuntu_mode", False) @@ -90,9 +100,7 @@ class LocaleGen(StateModuleHelper): self.LOCALE_SUPPORTED, self.LOCALE_GEN )) - if not self.is_available(): - self.do_raise("The locale you've entered is not available on your system.") - + self.assert_available() self.vars.set("is_present", self.is_present(), output=False) self.vars.set("state_tracking", self._state_name(self.vars.is_present), output=False, change=True) @@ -103,8 +111,8 @@ class LocaleGen(StateModuleHelper): def _state_name(present): return "present" if present else "absent" - def is_available(self): - """Check if the given locale is available on the system. This is done by + def assert_available(self): + """Check if the given locales are available on the system. This is done by checking either : * if the locale is present in /etc/locales.gen * or if the locale is present in /usr/share/i18n/SUPPORTED""" @@ -120,18 +128,35 @@ class LocaleGen(StateModuleHelper): res = [re_compiled.match(line) for line in lines] if self.verbosity >= 4: self.vars.available_lines = lines - if any(r.group("locale") == self.vars.name for r in res if r): - return True + + locales_not_found = [] + for locale in self.vars.name: + # Check if the locale is not found in any of the matches + if not any(match and match.group("locale") == locale for match in res): + locales_not_found.append(locale) + # locale may be installed but not listed in the file, for example C.UTF-8 in some systems - return self.is_present() + locales_not_found = self.locale_get_not_present(locales_not_found) + + if locales_not_found: + self.do_raise("The following locales you've entered are not available on your system: {0}".format(', '.join(locales_not_found))) def is_present(self): + return not self.locale_get_not_present(self.vars.name) + + def locale_get_not_present(self, locales): runner = locale_runner(self.module) with runner() as ctx: rc, out, err = ctx.run() if self.verbosity >= 4: self.vars.locale_run_info = ctx.run_info - return any(self.fix_case(self.vars.name) == self.fix_case(line) for line in out.splitlines()) + + not_found = [] + for locale in locales: + if not any(self.fix_case(locale) == self.fix_case(line) for line in out.splitlines()): + not_found.append(locale) + + return not_found def fix_case(self, name): """locale -a might return the encoding in either lower or upper case. @@ -140,39 +165,50 @@ class LocaleGen(StateModuleHelper): name = name.replace(s, r) return name - def set_locale(self, name, enabled=True): + def set_locale(self, names, enabled=True): """ Sets the state of the locale. Defaults to enabled. """ - search_string = r'#?\s*%s (?P.+)' % re.escape(name) - if enabled: - new_string = r'%s \g' % (name) - else: - new_string = r'# %s \g' % (name) - re_search = re.compile(search_string) - with open("/etc/locale.gen", "r") as fr: - lines = [re_search.sub(new_string, line) for line in fr] - with open("/etc/locale.gen", "w") as fw: - fw.write("".join(lines)) + with open("/etc/locale.gen", 'r') as fr: + lines = fr.readlines() - def apply_change(self, targetState, name): + locale_regexes = [] + + for name in names: + search_string = r'^#?\s*%s (?P.+)' % re.escape(name) + if enabled: + new_string = r'%s \g' % (name) + else: + new_string = r'# %s \g' % (name) + re_search = re.compile(search_string) + locale_regexes.append([re_search, new_string]) + + for i in range(len(lines)): + for [search, replace] in locale_regexes: + lines[i] = search.sub(replace, lines[i]) + + # Write the modified content back to the file + with open("/etc/locale.gen", 'w') as fw: + fw.writelines(lines) + + def apply_change(self, targetState, names): """Create or remove locale. Keyword arguments: targetState -- Desired state, either present or absent. - name -- Name including encoding such as de_CH.UTF-8. + names -- Names list including encoding such as de_CH.UTF-8. """ - self.set_locale(name, enabled=(targetState == "present")) + self.set_locale(names, enabled=(targetState == "present")) runner = locale_gen_runner(self.module) with runner() as ctx: ctx.run() - def apply_change_ubuntu(self, targetState, name): + def apply_change_ubuntu(self, targetState, names): """Create or remove locale. Keyword arguments: targetState -- Desired state, either present or absent. - name -- Name including encoding such as de_CH.UTF-8. + names -- Name list including encoding such as de_CH.UTF-8. """ runner = locale_gen_runner(self.module) @@ -188,7 +224,7 @@ class LocaleGen(StateModuleHelper): with open("/var/lib/locales/supported.d/local", "w") as fw: for line in content: locale, charset = line.split(' ') - if locale != name: + if locale not in names: fw.write(line) # Purge locales and regenerate. # Please provide a patch if you know how to avoid regenerating the locales to keep! diff --git a/plugins/modules/lvg.py b/plugins/modules/lvg.py index 8a6384369a..7ff7e3a2e7 100644 --- a/plugins/modules/lvg.py +++ b/plugins/modules/lvg.py @@ -179,7 +179,7 @@ def parse_vgs(data): def find_mapper_device_name(module, dm_device): dmsetup_cmd = module.get_bin_path('dmsetup', True) mapper_prefix = '/dev/mapper/' - rc, dm_name, err = module.run_command("%s info -C --noheadings -o name %s" % (dmsetup_cmd, dm_device)) + rc, dm_name, err = module.run_command([dmsetup_cmd, "info", "-C", "--noheadings", "-o", "name", dm_device]) if rc != 0: module.fail_json(msg="Failed executing dmsetup command.", rc=rc, err=err) mapper_device = mapper_prefix + dm_name.rstrip() @@ -204,7 +204,7 @@ def find_vg(module, vg): if not vg: return None vgs_cmd = module.get_bin_path('vgs', True) - dummy, current_vgs, dummy = module.run_command("%s --noheadings -o vg_name,pv_count,lv_count --separator ';'" % vgs_cmd, check_rc=True) + dummy, current_vgs, dummy = module.run_command([vgs_cmd, "--noheadings", "-o", "vg_name,pv_count,lv_count", "--separator", ";"], check_rc=True) vgs = parse_vgs(current_vgs) @@ -431,10 +431,10 @@ def main(): for x in itertools.chain(dev_list, module.params['pvs']) ) pvs_filter_vg_name = 'vg_name = {0}'.format(vg) - pvs_filter = "--select '{0} || {1}' ".format(pvs_filter_pv_name, pvs_filter_vg_name) + pvs_filter = ["--select", "{0} || {1}".format(pvs_filter_pv_name, pvs_filter_vg_name)] else: - pvs_filter = '' - rc, current_pvs, err = module.run_command("%s --noheadings -o pv_name,vg_name --separator ';' %s" % (pvs_cmd, pvs_filter)) + pvs_filter = [] + rc, current_pvs, err = module.run_command([pvs_cmd, "--noheadings", "-o", "pv_name,vg_name", "--separator", ";"] + pvs_filter) if rc != 0: module.fail_json(msg="Failed executing pvs command.", rc=rc, err=err) @@ -473,7 +473,7 @@ def main(): if this_vg['lv_count'] == 0 or force: # remove VG vgremove_cmd = module.get_bin_path('vgremove', True) - rc, dummy, err = module.run_command("%s --force %s" % (vgremove_cmd, vg)) + rc, dummy, err = module.run_command([vgremove_cmd, "--force", vg]) if rc == 0: module.exit_json(changed=True) else: @@ -509,7 +509,6 @@ def main(): changed = True else: if devs_to_add: - devs_to_add_string = ' '.join(devs_to_add) # create PV pvcreate_cmd = module.get_bin_path('pvcreate', True) for current_dev in devs_to_add: @@ -520,21 +519,20 @@ def main(): module.fail_json(msg="Creating physical volume '%s' failed" % current_dev, rc=rc, err=err) # add PV to our VG vgextend_cmd = module.get_bin_path('vgextend', True) - rc, dummy, err = module.run_command("%s %s %s" % (vgextend_cmd, vg, devs_to_add_string)) + rc, dummy, err = module.run_command([vgextend_cmd, vg] + devs_to_add) if rc == 0: changed = True else: - module.fail_json(msg="Unable to extend %s by %s." % (vg, devs_to_add_string), rc=rc, err=err) + module.fail_json(msg="Unable to extend %s by %s." % (vg, ' '.join(devs_to_add)), rc=rc, err=err) # remove some PV from our VG if devs_to_remove: - devs_to_remove_string = ' '.join(devs_to_remove) vgreduce_cmd = module.get_bin_path('vgreduce', True) - rc, dummy, err = module.run_command("%s --force %s %s" % (vgreduce_cmd, vg, devs_to_remove_string)) + rc, dummy, err = module.run_command([vgreduce_cmd, "--force", vg] + devs_to_remove) if rc == 0: changed = True else: - module.fail_json(msg="Unable to reduce %s by %s." % (vg, devs_to_remove_string), rc=rc, err=err) + module.fail_json(msg="Unable to reduce %s by %s." % (vg, ' '.join(devs_to_remove)), rc=rc, err=err) module.exit_json(changed=changed) diff --git a/plugins/modules/lvol.py b/plugins/modules/lvol.py index a2a870260a..3a2f5c7cdd 100644 --- a/plugins/modules/lvol.py +++ b/plugins/modules/lvol.py @@ -236,6 +236,7 @@ EXAMPLES = ''' ''' import re +import shlex from ansible.module_utils.basic import AnsibleModule @@ -281,7 +282,7 @@ def parse_vgs(data): def get_lvm_version(module): ver_cmd = module.get_bin_path("lvm", required=True) - rc, out, err = module.run_command("%s version" % (ver_cmd)) + rc, out, err = module.run_command([ver_cmd, "version"]) if rc != 0: return None m = re.search(r"LVM version:\s+(\d+)\.(\d+)\.(\d+).*(\d{4}-\d{2}-\d{2})", out) @@ -320,14 +321,14 @@ def main(): module.fail_json(msg="Failed to get LVM version number") version_yesopt = mkversion(2, 2, 99) # First LVM with the "--yes" option if version_found >= version_yesopt: - yesopt = "--yes" + yesopt = ["--yes"] else: - yesopt = "" + yesopt = [] vg = module.params['vg'] lv = module.params['lv'] size = module.params['size'] - opts = module.params['opts'] + opts = shlex.split(module.params['opts'] or '') state = module.params['state'] force = module.boolean(module.params['force']) shrink = module.boolean(module.params['shrink']) @@ -338,21 +339,13 @@ def main(): size_unit = 'm' size_operator = None snapshot = module.params['snapshot'] - pvs = module.params['pvs'] - - if pvs is None: - pvs = "" - else: - pvs = " ".join(pvs) - - if opts is None: - opts = "" + pvs = module.params['pvs'] or [] # Add --test option when running in check-mode if module.check_mode: - test_opt = ' --test' + test_opt = ['--test'] else: - test_opt = '' + test_opt = [] if size: # LVEXTEND(8)/LVREDUCE(8) -l, -L options: Check for relative value for resizing @@ -400,7 +393,7 @@ def main(): # Get information on volume group requested vgs_cmd = module.get_bin_path("vgs", required=True) rc, current_vgs, err = module.run_command( - "%s --noheadings --nosuffix -o vg_name,size,free,vg_extent_size --units %s --separator ';' %s" % (vgs_cmd, unit.lower(), vg)) + [vgs_cmd, "--noheadings", "--nosuffix", "-o", "vg_name,size,free,vg_extent_size", "--units", unit.lower(), "--separator", ";", vg]) if rc != 0: if state == 'absent': @@ -414,7 +407,7 @@ def main(): # Get information on logical volume requested lvs_cmd = module.get_bin_path("lvs", required=True) rc, current_lvs, err = module.run_command( - "%s -a --noheadings --nosuffix -o lv_name,size,lv_attr --units %s --separator ';' %s" % (lvs_cmd, unit.lower(), vg)) + [lvs_cmd, "-a", "--noheadings", "--nosuffix", "-o", "lv_name,size,lv_attr", "--units", unit.lower(), "--separator", ";", vg]) if rc != 0: if state == 'absent': @@ -474,20 +467,23 @@ def main(): # create LV lvcreate_cmd = module.get_bin_path("lvcreate", required=True) + cmd = [lvcreate_cmd] + test_opt + yesopt if snapshot is not None: if size: - cmd = "%s %s %s -%s %s%s -s -n %s %s %s/%s" % (lvcreate_cmd, test_opt, yesopt, size_opt, size, size_unit, snapshot, opts, vg, lv) - else: - cmd = "%s %s %s -s -n %s %s %s/%s" % (lvcreate_cmd, test_opt, yesopt, snapshot, opts, vg, lv) - elif thinpool and lv: - if size_opt == 'l': - module.fail_json(changed=False, msg="Thin volume sizing with percentage not supported.") - size_opt = 'V' - cmd = "%s %s %s -n %s -%s %s%s %s -T %s/%s" % (lvcreate_cmd, test_opt, yesopt, lv, size_opt, size, size_unit, opts, vg, thinpool) - elif thinpool and not lv: - cmd = "%s %s %s -%s %s%s %s -T %s/%s" % (lvcreate_cmd, test_opt, yesopt, size_opt, size, size_unit, opts, vg, thinpool) + cmd += ["-%s" % size_opt, "%s%s" % (size, size_unit)] + cmd += ["-s", "-n", snapshot] + opts + ["%s/%s" % (vg, lv)] + elif thinpool: + if lv: + if size_opt == 'l': + module.fail_json(changed=False, msg="Thin volume sizing with percentage not supported.") + size_opt = 'V' + cmd += ["-n", lv] + cmd += ["-%s" % size_opt, "%s%s" % (size, size_unit)] + cmd += opts + ["-T", "%s/%s" % (vg, thinpool)] else: - cmd = "%s %s %s -n %s -%s %s%s %s %s %s" % (lvcreate_cmd, test_opt, yesopt, lv, size_opt, size, size_unit, opts, vg, pvs) + cmd += ["-n", lv] + cmd += ["-%s" % size_opt, "%s%s" % (size, size_unit)] + cmd += opts + [vg] + pvs rc, dummy, err = module.run_command(cmd) if rc == 0: changed = True @@ -499,7 +495,7 @@ def main(): if not force: module.fail_json(msg="Sorry, no removal of logical volume %s without force=true." % (this_lv['name'])) lvremove_cmd = module.get_bin_path("lvremove", required=True) - rc, dummy, err = module.run_command("%s %s --force %s/%s" % (lvremove_cmd, test_opt, vg, this_lv['name'])) + rc, dummy, err = module.run_command([lvremove_cmd] + test_opt + ["--force", "%s/%s" % (vg, this_lv['name'])]) if rc == 0: module.exit_json(changed=True) else: @@ -527,7 +523,7 @@ def main(): if this_lv['size'] < size_requested: if (size_free > 0) and (size_free >= (size_requested - this_lv['size'])): - tool = module.get_bin_path("lvextend", required=True) + tool = [module.get_bin_path("lvextend", required=True)] else: module.fail_json( msg="Logical Volume %s could not be extended. Not enough free space left (%s%s required / %s%s available)" % @@ -539,16 +535,17 @@ def main(): elif not force: module.fail_json(msg="Sorry, no shrinking of %s without force=true" % (this_lv['name'])) else: - tool = module.get_bin_path("lvreduce", required=True) - tool = '%s %s' % (tool, '--force') + tool = [module.get_bin_path("lvreduce", required=True), '--force'] if tool: if resizefs: - tool = '%s %s' % (tool, '--resizefs') + tool += ['--resizefs'] + cmd = tool + test_opt if size_operator: - cmd = "%s %s -%s %s%s%s %s/%s %s" % (tool, test_opt, size_opt, size_operator, size, size_unit, vg, this_lv['name'], pvs) + cmd += ["-%s" % size_opt, "%s%s%s" % (size_operator, size, size_unit)] else: - cmd = "%s %s -%s %s%s %s/%s %s" % (tool, test_opt, size_opt, size, size_unit, vg, this_lv['name'], pvs) + cmd += ["-%s" % size_opt, "%s%s" % (size, size_unit)] + cmd += ["%s/%s" % (vg, this_lv['name'])] + pvs rc, out, err = module.run_command(cmd) if "Reached maximum COW size" in out: module.fail_json(msg="Unable to resize %s to %s%s" % (lv, size, size_unit), rc=rc, err=err, out=out) @@ -566,23 +563,24 @@ def main(): # resize LV based on absolute values tool = None if float(size) > this_lv['size'] or size_operator == '+': - tool = module.get_bin_path("lvextend", required=True) + tool = [module.get_bin_path("lvextend", required=True)] elif shrink and float(size) < this_lv['size'] or size_operator == '-': if float(size) == 0: module.fail_json(msg="Sorry, no shrinking of %s to 0 permitted." % (this_lv['name'])) if not force: module.fail_json(msg="Sorry, no shrinking of %s without force=true." % (this_lv['name'])) else: - tool = module.get_bin_path("lvreduce", required=True) - tool = '%s %s' % (tool, '--force') + tool = [module.get_bin_path("lvreduce", required=True), '--force'] if tool: if resizefs: - tool = '%s %s' % (tool, '--resizefs') + tool += ['--resizefs'] + cmd = tool + test_opt if size_operator: - cmd = "%s %s -%s %s%s%s %s/%s %s" % (tool, test_opt, size_opt, size_operator, size, size_unit, vg, this_lv['name'], pvs) + cmd += ["-%s" % size_opt, "%s%s%s" % (size_operator, size, size_unit)] else: - cmd = "%s %s -%s %s%s %s/%s %s" % (tool, test_opt, size_opt, size, size_unit, vg, this_lv['name'], pvs) + cmd += ["-%s" % size_opt, "%s%s" % (size, size_unit)] + cmd += ["%s/%s" % (vg, this_lv['name'])] + pvs rc, out, err = module.run_command(cmd) if "Reached maximum COW size" in out: module.fail_json(msg="Unable to resize %s to %s%s" % (lv, size, size_unit), rc=rc, err=err, out=out) @@ -598,14 +596,14 @@ def main(): if this_lv is not None: if active: lvchange_cmd = module.get_bin_path("lvchange", required=True) - rc, dummy, err = module.run_command("%s -ay %s/%s" % (lvchange_cmd, vg, this_lv['name'])) + rc, dummy, err = module.run_command([lvchange_cmd, "-ay", "%s/%s" % (vg, this_lv['name'])]) if rc == 0: module.exit_json(changed=((not this_lv['active']) or changed), vg=vg, lv=this_lv['name'], size=this_lv['size']) else: module.fail_json(msg="Failed to activate logical volume %s" % (lv), rc=rc, err=err) else: lvchange_cmd = module.get_bin_path("lvchange", required=True) - rc, dummy, err = module.run_command("%s -an %s/%s" % (lvchange_cmd, vg, this_lv['name'])) + rc, dummy, err = module.run_command([lvchange_cmd, "-an", "%s/%s" % (vg, this_lv['name'])]) if rc == 0: module.exit_json(changed=(this_lv['active'] or changed), vg=vg, lv=this_lv['name'], size=this_lv['size']) else: diff --git a/plugins/modules/lxc_container.py b/plugins/modules/lxc_container.py index 7ded041e93..2d768eaafd 100644 --- a/plugins/modules/lxc_container.py +++ b/plugins/modules/lxc_container.py @@ -683,11 +683,11 @@ class LxcContainerManagement(object): variables.pop(v, None) false_values = BOOLEANS_FALSE.union([None, '']) - result = dict( - (v, self.module.params[k]) + result = { + v: self.module.params[k] for k, v in variables.items() if self.module.params[k] not in false_values - ) + } return result def _config(self): diff --git a/plugins/modules/lxd_container.py b/plugins/modules/lxd_container.py index 9fd1b183be..88e502e7c8 100644 --- a/plugins/modules/lxd_container.py +++ b/plugins/modules/lxd_container.py @@ -86,8 +86,8 @@ options: source: description: - 'The source for the instance - (for example V({ "type": "image", "mode": "pull", "server": "https://images.linuxcontainers.org", - "protocol": "lxd", "alias": "ubuntu/xenial/amd64" })).' + (for example V({ "type": "image", "mode": "pull", "server": "https://cloud-images.ubuntu.com/releases/", + "protocol": "simplestreams", "alias": "22.04" })).' - 'See U(https://documentation.ubuntu.com/lxd/en/latest/api/) for complete API documentation.' - 'Note that C(protocol) accepts two choices: V(lxd) or V(simplestreams).' required: false @@ -205,6 +205,9 @@ notes: - You can copy a file in the created instance to the localhost with C(command=lxc file pull instance_name/dir/filename filename). See the first example below. + - linuxcontainers.org has phased out LXC/LXD support with March 2024 + (U(https://discuss.linuxcontainers.org/t/important-notice-for-lxd-users-image-server/18479)). + Currently only Ubuntu is still providing images. ''' EXAMPLES = ''' @@ -220,9 +223,9 @@ EXAMPLES = ''' source: type: image mode: pull - server: https://images.linuxcontainers.org - protocol: lxd # if you get a 404, try setting protocol: simplestreams - alias: ubuntu/xenial/amd64 + server: https://cloud-images.ubuntu.com/releases/ + protocol: simplestreams + alias: "22.04" profiles: ["default"] wait_for_ipv4_addresses: true timeout: 600 @@ -264,6 +267,26 @@ EXAMPLES = ''' wait_for_ipv4_addresses: true timeout: 600 +# An example of creating a ubuntu-minial container +- hosts: localhost + connection: local + tasks: + - name: Create a started container + community.general.lxd_container: + name: mycontainer + ignore_volatile_options: true + state: started + source: + type: image + mode: pull + # Provides Ubuntu minimal images + server: https://cloud-images.ubuntu.com/minimal/releases/ + protocol: simplestreams + alias: "22.04" + profiles: ["default"] + wait_for_ipv4_addresses: true + timeout: 600 + # An example for creating container in project other than default - hosts: localhost connection: local @@ -278,8 +301,8 @@ EXAMPLES = ''' protocol: simplestreams type: image mode: pull - server: https://images.linuxcontainers.org - alias: ubuntu/20.04/cloud + server: https://cloud-images.ubuntu.com/releases/ + alias: "22.04" profiles: ["default"] wait_for_ipv4_addresses: true timeout: 600 @@ -347,7 +370,7 @@ EXAMPLES = ''' source: type: image mode: pull - alias: ubuntu/xenial/amd64 + alias: "22.04" target: node01 - name: Create container on another node @@ -358,7 +381,7 @@ EXAMPLES = ''' source: type: image mode: pull - alias: ubuntu/xenial/amd64 + alias: "22.04" target: node02 # An example for creating a virtual machine @@ -377,7 +400,7 @@ EXAMPLES = ''' protocol: simplestreams type: image mode: pull - server: https://images.linuxcontainers.org + server: [...] # URL to the image server alias: debian/11 timeout: 600 ''' @@ -593,8 +616,15 @@ class LXDContainerManagement(object): def _instance_ipv4_addresses(self, ignore_devices=None): ignore_devices = ['lo'] if ignore_devices is None else ignore_devices data = (self._get_instance_state_json() or {}).get('metadata', None) or {} - network = dict((k, v) for k, v in (data.get('network', None) or {}).items() if k not in ignore_devices) - addresses = dict((k, [a['address'] for a in v['addresses'] if a['family'] == 'inet']) for k, v in network.items()) + network = { + k: v + for k, v in data.get('network', {}).items() + if k not in ignore_devices + } + addresses = { + k: [a['address'] for a in v['addresses'] if a['family'] == 'inet'] + for k, v in network.items() + } return addresses @staticmethod @@ -725,19 +755,22 @@ class LXDContainerManagement(object): def run(self): """Run the main method.""" + def adjust_content(content): + return content if not isinstance(content, dict) else { + k: v for k, v in content.items() if not (self.ignore_volatile_options and k.startswith('volatile.')) + } + try: if self.trust_password is not None: self.client.authenticate(self.trust_password) self.ignore_volatile_options = self.module.params.get('ignore_volatile_options') self.old_instance_json = self._get_instance_json() - self.old_sections = dict( - (section, content) if not isinstance(content, dict) - else (section, dict((k, v) for k, v in content.items() - if not (self.ignore_volatile_options and k.startswith('volatile.')))) - for section, content in (self.old_instance_json.get('metadata', None) or {}).items() + self.old_sections = { + section: adjust_content(content) + for section, content in self.old_instance_json.get('metadata', {}).items() if section in set(CONFIG_PARAMS) - set(CONFIG_CREATION_PARAMS) - ) + } self.diff['before']['instance'] = self.old_sections # preliminary, will be overwritten in _apply_instance_configs() if called diff --git a/plugins/modules/macports.py b/plugins/modules/macports.py index e81fb9142c..cd620687d7 100644 --- a/plugins/modules/macports.py +++ b/plugins/modules/macports.py @@ -111,7 +111,7 @@ from ansible.module_utils.basic import AnsibleModule def selfupdate(module, port_path): """ Update Macports and the ports tree. """ - rc, out, err = module.run_command("%s -v selfupdate" % port_path) + rc, out, err = module.run_command([port_path, "-v", "selfupdate"]) if rc == 0: updated = any( @@ -135,7 +135,7 @@ def selfupdate(module, port_path): def upgrade(module, port_path): """ Upgrade outdated ports. """ - rc, out, err = module.run_command("%s upgrade outdated" % port_path) + rc, out, err = module.run_command([port_path, "upgrade", "outdated"]) # rc is 1 when nothing to upgrade so check stdout first. if out.strip() == "Nothing to upgrade.": @@ -182,7 +182,7 @@ def remove_ports(module, port_path, ports, stdout, stderr): if not query_port(module, port_path, port): continue - rc, out, err = module.run_command("%s uninstall %s" % (port_path, port)) + rc, out, err = module.run_command([port_path, "uninstall", port]) stdout += out stderr += err if query_port(module, port_path, port): @@ -206,7 +206,7 @@ def install_ports(module, port_path, ports, variant, stdout, stderr): if query_port(module, port_path, port): continue - rc, out, err = module.run_command("%s install %s %s" % (port_path, port, variant)) + rc, out, err = module.run_command([port_path, "install", port, variant]) stdout += out stderr += err if not query_port(module, port_path, port): @@ -232,7 +232,7 @@ def activate_ports(module, port_path, ports, stdout, stderr): if query_port(module, port_path, port, state="active"): continue - rc, out, err = module.run_command("%s activate %s" % (port_path, port)) + rc, out, err = module.run_command([port_path, "activate", port]) stdout += out stderr += err @@ -259,7 +259,7 @@ def deactivate_ports(module, port_path, ports, stdout, stderr): if not query_port(module, port_path, port, state="active"): continue - rc, out, err = module.run_command("%s deactivate %s" % (port_path, port)) + rc, out, err = module.run_command([port_path, "deactivate", port]) stdout += out stderr += err if query_port(module, port_path, port, state="active"): diff --git a/plugins/modules/manageiq_provider.py b/plugins/modules/manageiq_provider.py index e6ded9ea7a..af5c147f46 100644 --- a/plugins/modules/manageiq_provider.py +++ b/plugins/modules/manageiq_provider.py @@ -715,7 +715,7 @@ def delete_nulls(h): if isinstance(h, list): return [delete_nulls(i) for i in h] if isinstance(h, dict): - return dict((k, delete_nulls(v)) for k, v in h.items() if v is not None) + return {k: delete_nulls(v) for k, v in h.items() if v is not None} return h diff --git a/plugins/modules/maven_artifact.py b/plugins/modules/maven_artifact.py index 0dc020c37a..e239b4a164 100644 --- a/plugins/modules/maven_artifact.py +++ b/plugins/modules/maven_artifact.py @@ -11,7 +11,7 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type -DOCUMENTATION = ''' +DOCUMENTATION = r''' --- module: maven_artifact short_description: Downloads an Artifact from a Maven Repository @@ -22,7 +22,7 @@ description: author: "Chris Schmidt (@chrisisbeef)" requirements: - lxml - - boto if using a S3 repository (s3://...) + - boto if using a S3 repository (V(s3://...)) attributes: check_mode: support: none @@ -32,52 +32,52 @@ options: group_id: type: str description: - - The Maven groupId coordinate + - The Maven groupId coordinate. required: true artifact_id: type: str description: - - The maven artifactId coordinate + - The maven artifactId coordinate. required: true version: type: str description: - - The maven version coordinate + - The maven version coordinate. - Mutually exclusive with O(version_by_spec). version_by_spec: type: str description: - The maven dependency version ranges. - See supported version ranges on U(https://cwiki.apache.org/confluence/display/MAVENOLD/Dependency+Mediation+and+Conflict+Resolution) - - The range type "(,1.0],[1.2,)" and "(,1.1),(1.1,)" is not supported. + - The range type V((,1.0],[1.2,\)) and V((,1.1\),(1.1,\)) is not supported. - Mutually exclusive with O(version). version_added: '0.2.0' classifier: type: str description: - - The maven classifier coordinate + - The maven classifier coordinate. default: '' extension: type: str description: - - The maven type/extension coordinate + - The maven type/extension coordinate. default: jar repository_url: type: str description: - The URL of the Maven Repository to download from. - - Use s3://... if the repository is hosted on Amazon S3, added in version 2.2. - - Use file://... if the repository is local, added in version 2.6 + - Use V(s3://...) if the repository is hosted on Amazon S3. + - Use V(file://...) if the repository is local. default: https://repo1.maven.org/maven2 username: type: str description: - - The username to authenticate as to the Maven Repository. Use AWS secret key of the repository is hosted on S3 + - The username to authenticate as to the Maven Repository. Use AWS secret key of the repository is hosted on S3. aliases: [ "aws_secret_key" ] password: type: str description: - - The password to authenticate with to the Maven Repository. Use AWS secret access key of the repository is hosted on S3 + - The password to authenticate with to the Maven Repository. Use AWS secret access key of the repository is hosted on S3. aliases: [ "aws_secret_access_key" ] headers: description: @@ -95,19 +95,19 @@ options: dest: type: path description: - - The path where the artifact should be written to - - If file mode or ownerships are specified and destination path already exists, they affect the downloaded file + - The path where the artifact should be written to. + - If file mode or ownerships are specified and destination path already exists, they affect the downloaded file. required: true state: type: str description: - - The desired state of the artifact + - The desired state of the artifact. default: present choices: [present,absent] timeout: type: int description: - - Specifies a timeout in seconds for the connection attempt + - Specifies a timeout in seconds for the connection attempt. default: 10 validate_certs: description: diff --git a/plugins/modules/memset_zone_record.py b/plugins/modules/memset_zone_record.py index 8406d93d21..80838a26a3 100644 --- a/plugins/modules/memset_zone_record.py +++ b/plugins/modules/memset_zone_record.py @@ -181,6 +181,7 @@ def api_validation(args=None): https://www.memset.com/apidocs/methods_dns.html#dns.zone_record_create) ''' failed_validation = False + error = None # priority can only be integer 0 > 999 if not 0 <= args['priority'] <= 999: diff --git a/plugins/modules/mksysb.py b/plugins/modules/mksysb.py index 8272dbf7de..1280f04d59 100644 --- a/plugins/modules/mksysb.py +++ b/plugins/modules/mksysb.py @@ -138,6 +138,7 @@ class MkSysB(ModuleHelper): backup_dmapi_fs=cmd_runner_fmt.as_bool("-A"), combined_path=cmd_runner_fmt.as_func(cmd_runner_fmt.unpack_args(lambda p, n: ["%s/%s" % (p, n)])), ) + use_old_vardict = False def __init_module__(self): if not os.path.isdir(self.vars.storage_path): diff --git a/plugins/modules/nagios.py b/plugins/modules/nagios.py index 783aa88e24..0f1f0b7c50 100644 --- a/plugins/modules/nagios.py +++ b/plugins/modules/nagios.py @@ -39,8 +39,6 @@ options: action: description: - Action to take. - - servicegroup options were added in 2.0. - - delete_downtime options were added in 2.2. - The V(acknowledge) and V(forced_check) actions were added in community.general 1.2.0. required: true choices: [ "downtime", "delete_downtime", "enable_alerts", "disable_alerts", "silence", "unsilence", diff --git a/plugins/modules/nmcli.py b/plugins/modules/nmcli.py index 9360ce37d3..6f0884da92 100644 --- a/plugins/modules/nmcli.py +++ b/plugins/modules/nmcli.py @@ -64,13 +64,16 @@ options: - Type V(infiniband) is added in community.general 2.0.0. - Type V(loopback) is added in community.general 8.1.0. - Type V(macvlan) is added in community.general 6.6.0. + - Type V(ovs-bridge) is added in community.general 8.6.0. + - Type V(ovs-interface) is added in community.general 8.6.0. + - Type V(ovs-port) is added in community.general 8.6.0. - Type V(wireguard) is added in community.general 4.3.0. - Type V(vpn) is added in community.general 5.1.0. - Using V(bond-slave), V(bridge-slave), or V(team-slave) implies V(ethernet) connection type with corresponding O(slave_type) option. - If you want to control non-ethernet connection attached to V(bond), V(bridge), or V(team) consider using O(slave_type) option. type: str choices: [ bond, bond-slave, bridge, bridge-slave, dummy, ethernet, generic, gre, infiniband, ipip, macvlan, sit, team, team-slave, vlan, vxlan, - wifi, gsm, wireguard, vpn, loopback ] + wifi, gsm, wireguard, ovs-bridge, ovs-port, ovs-interface, vpn, loopback ] mode: description: - This is the type of device or network connection that you wish to create for a bond or bridge. @@ -86,12 +89,13 @@ options: slave_type: description: - Type of the device of this slave's master connection (for example V(bond)). + - Type V(ovs-port) is added in community.general 8.6.0. type: str - choices: [ 'bond', 'bridge', 'team' ] + choices: [ 'bond', 'bridge', 'team', 'ovs-port' ] version_added: 7.0.0 master: description: - - Master +# Copyright (c) 2021, Jyrki Gadinger # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later @@ -60,7 +60,7 @@ extends_documentation_fragment: - community.general.attributes author: - - "Georg Gadinger (@nilsding)" + - "Jyrki Gadinger (@nilsding)" ''' EXAMPLES = ''' diff --git a/plugins/modules/one_vm.py b/plugins/modules/one_vm.py index 8ee9c85609..2f4ee25354 100644 --- a/plugins/modules/one_vm.py +++ b/plugins/modules/one_vm.py @@ -1559,11 +1559,11 @@ def main(): one_client = pyone.OneServer(auth.url, session=auth.username + ':' + auth.password) if attributes: - attributes = dict((key.upper(), value) for key, value in attributes.items()) + attributes = {key.upper(): value for key, value in attributes.items()} check_attributes(module, attributes) if count_attributes: - count_attributes = dict((key.upper(), value) for key, value in count_attributes.items()) + count_attributes = {key.upper(): value for key, value in count_attributes.items()} if not attributes: import copy module.warn('When you pass `count_attributes` without `attributes` option when deploying, `attributes` option will have same values implicitly.') diff --git a/plugins/modules/one_vnet.py b/plugins/modules/one_vnet.py new file mode 100644 index 0000000000..93523f8b4f --- /dev/null +++ b/plugins/modules/one_vnet.py @@ -0,0 +1,434 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2024, Alexander Bakanovskii +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: one_vnet +short_description: Manages OpenNebula virtual networks +version_added: 9.4.0 +author: "Alexander Bakanovskii (@abakanovskii)" +requirements: + - pyone +description: + - Manages virtual networks in OpenNebula. +attributes: + check_mode: + support: partial + details: + - Note that check mode always returns C(changed=true) for existing networks, even if the network would not actually change. + diff_mode: + support: none +options: + id: + description: + - A O(id) of the network you would like to manage. + - If not set then a new network will be created with the given O(name). + type: int + name: + description: + - A O(name) of the network you would like to manage. If a network with + the given name does not exist it will be created, otherwise it will be + managed by this module. + type: str + template: + description: + - A string containing the network template contents. + type: str + state: + description: + - V(present) - state that is used to manage the network. + - V(absent) - delete the network. + choices: ["present", "absent"] + default: present + type: str + +extends_documentation_fragment: + - community.general.opennebula + - community.general.attributes +''' + +EXAMPLES = ''' +- name: Make sure the network is present by ID + community.general.one_vnet: + id: 0 + state: present + register: result + +- name: Make sure the network is present by name + community.general.one_vnet: + name: opennebula-bridge + state: present + register: result + +- name: Create a new or update an existing network + community.general.one_vnet: + name: bridge-network + template: | + VN_MAD = "bridge" + BRIDGE = "br0" + BRIDGE_TYPE = "linux" + AR=[ + TYPE = "IP4", + IP = 192.0.2.50, + SIZE = "20" + ] + DNS = 192.0.2.1 + GATEWAY = 192.0.2.1 + +- name: Delete the network by ID + community.general.one_vnet: + id: 0 + state: absent +''' + +RETURN = ''' +id: + description: The network id. + type: int + returned: when O(state=present) + sample: 153 +name: + description: The network name. + type: str + returned: when O(state=present) + sample: app1 +template: + description: The parsed network template. + type: dict + returned: when O(state=present) + sample: + BRIDGE: onebr.1000 + BRIDGE_TYPE: linux + DESCRIPTION: sampletext + PHYDEV: eth0 + SECURITY_GROUPS: 0 + VLAN_ID: 1000 + VN_MAD: 802.1Q +user_id: + description: The network's user name. + type: int + returned: when O(state=present) + sample: 1 +user_name: + description: The network's user id. + type: str + returned: when O(state=present) + sample: oneadmin +group_id: + description: The network's group id. + type: int + returned: when O(state=present) + sample: 1 +group_name: + description: The network's group name. + type: str + returned: when O(state=present) + sample: one-users +owner_id: + description: The network's owner id. + type: int + returned: when O(state=present) + sample: 143 +owner_name: + description: The network's owner name. + type: str + returned: when O(state=present) + sample: ansible-test +permissions: + description: The network's permissions. + type: dict + returned: when O(state=present) + contains: + owner_u: + description: The network's owner USAGE permissions. + type: str + sample: 1 + owner_m: + description: The network's owner MANAGE permissions. + type: str + sample: 0 + owner_a: + description: The network's owner ADMIN permissions. + type: str + sample: 0 + group_u: + description: The network's group USAGE permissions. + type: str + sample: 0 + group_m: + description: The network's group MANAGE permissions. + type: str + sample: 0 + group_a: + description: The network's group ADMIN permissions. + type: str + sample: 0 + other_u: + description: The network's other users USAGE permissions. + type: str + sample: 0 + other_m: + description: The network's other users MANAGE permissions. + type: str + sample: 0 + other_a: + description: The network's other users ADMIN permissions + type: str + sample: 0 + sample: + owner_u: 1 + owner_m: 0 + owner_a: 0 + group_u: 0 + group_m: 0 + group_a: 0 + other_u: 0 + other_m: 0 + other_a: 0 +clusters: + description: The network's clusters. + type: list + returned: when O(state=present) + sample: [0, 100] +bridge: + description: The network's bridge interface. + type: str + returned: when O(state=present) + sample: br0 +bridge_type: + description: The network's bridge type. + type: str + returned: when O(state=present) + sample: linux +parent_network_id: + description: The network's parent network id. + type: int + returned: when O(state=present) + sample: 1 +vm_mad: + description: The network's VM_MAD. + type: str + returned: when O(state=present) + sample: bridge +phydev: + description: The network's physical device (NIC). + type: str + returned: when O(state=present) + sample: eth0 +vlan_id: + description: The network's VLAN tag. + type: int + returned: when O(state=present) + sample: 1000 +outer_vlan_id: + description: The network's outer VLAN tag. + type: int + returned: when O(state=present) + sample: 1000 +vrouters: + description: The network's list of virtual routers IDs. + type: list + returned: when O(state=present) + sample: [0, 1] +ar_pool: + description: The network's list of ar_pool. + type: list + returned: when O(state=present) + sample: + - ar_id: 0 + ip: 192.0.2.1 + mac: 6c:1e:46:01:cd:d1 + size: 20 + type: IP4 + - ar_id: 1 + allocated: 0 + ip: 198.51.100.1 + mac: 5d:9b:c0:9e:f6:e5 + size: 20 + type: IP4 +''' + + +from ansible_collections.community.general.plugins.module_utils.opennebula import OpenNebulaModule + + +class NetworksModule(OpenNebulaModule): + + def __init__(self): + argument_spec = dict( + id=dict(type='int', required=False), + name=dict(type='str', required=False), + state=dict(type='str', choices=['present', 'absent'], default='present'), + template=dict(type='str', required=False), + ) + + mutually_exclusive = [ + ['id', 'name'] + ] + + required_one_of = [('id', 'name')] + + required_if = [ + ['state', 'present', ['template']] + ] + + OpenNebulaModule.__init__(self, + argument_spec, + supports_check_mode=True, + mutually_exclusive=mutually_exclusive, + required_one_of=required_one_of, + required_if=required_if) + + def run(self, one, module, result): + params = module.params + id = params.get('id') + name = params.get('name') + desired_state = params.get('state') + template_data = params.get('template') + + self.result = {} + + template = self.get_template_instance(id, name) + needs_creation = False + if not template and desired_state != 'absent': + if id: + module.fail_json(msg="There is no template with id=" + str(id)) + else: + needs_creation = True + + if desired_state == 'absent': + self.result = self.delete_template(template) + else: + if needs_creation: + self.result = self.create_template(name, template_data) + else: + self.result = self.update_template(template, template_data) + + self.exit() + + def get_template(self, predicate): + # -2 means "Resources belonging to all users" + # the other two parameters are used for pagination, -1 for both essentially means "return all" + pool = self.one.vnpool.info(-2, -1, -1) + + for template in pool.VMTEMPLATE: + if predicate(template): + return template + + return None + + def get_template_by_id(self, template_id): + return self.get_template(lambda template: (template.ID == template_id)) + + def get_template_by_name(self, name): + return self.get_template(lambda template: (template.NAME == name)) + + def get_template_instance(self, requested_id, requested_name): + if requested_id: + return self.get_template_by_id(requested_id) + else: + return self.get_template_by_name(requested_name) + + def get_networks_ar_pool(self, template): + ar_pool = [] + for ar in template.AR_POOL: + ar_pool.append({ + # These params will always be present + 'ar_id': ar['AR_ID'], + 'mac': ar['MAC'], + 'size': ar['SIZE'], + 'type': ar['TYPE'], + # These are optional so firstly check for presence + # and if not present set value to Null + 'allocated': getattr(ar, 'ALLOCATED', 'Null'), + 'ip': getattr(ar, 'IP', 'Null'), + 'global_prefix': getattr(ar, 'GLOBAL_PREFIX', 'Null'), + 'parent_network_ar_id': getattr(ar, 'PARENT_NETWORK_AR_ID', 'Null'), + 'ula_prefix': getattr(ar, 'ULA_PREFIX', 'Null'), + 'vn_mad': getattr(ar, 'VN_MAD', 'Null'), + }) + return ar_pool + + def get_template_info(self, template): + info = { + 'id': template.ID, + 'name': template.NAME, + 'template': template.TEMPLATE, + 'user_name': template.UNAME, + 'user_id': template.UID, + 'group_name': template.GNAME, + 'group_id': template.GID, + 'permissions': { + 'owner_u': template.PERMISSIONS.OWNER_U, + 'owner_m': template.PERMISSIONS.OWNER_M, + 'owner_a': template.PERMISSIONS.OWNER_A, + 'group_u': template.PERMISSIONS.GROUP_U, + 'group_m': template.PERMISSIONS.GROUP_M, + 'group_a': template.PERMISSIONS.GROUP_A, + 'other_u': template.PERMISSIONS.OTHER_U, + 'other_m': template.PERMISSIONS.OTHER_M, + 'other_a': template.PERMISSIONS.OTHER_A + }, + 'clusters': template.CLUSTERS.ID, + 'bridge': template.BRIDGE, + 'bride_type': template.BRIDGE_TYPE, + 'parent_network_id': template.PARENT_NETWORK_ID, + 'vm_mad': template.VM_MAD, + 'phydev': template.PHYDEV, + 'vlan_id': template.VLAN_ID, + 'outer_vlan_id': template.OUTER_VLAN_ID, + 'used_leases': template.USED_LEASES, + 'vrouters': template.VROUTERS.ID, + 'ar_pool': self.get_networks_ar_pool(template) + } + + return info + + def create_template(self, name, template_data): + if not self.module.check_mode: + self.one.vn.allocate("NAME = \"" + name + "\"\n" + template_data) + + result = self.get_template_info(self.get_template_by_name(name)) + result['changed'] = True + + return result + + def update_template(self, template, template_data): + if not self.module.check_mode: + # 0 = replace the whole template + self.one.vn.update(template.ID, template_data, 0) + + result = self.get_template_info(self.get_template_by_id(template.ID)) + if self.module.check_mode: + # Unfortunately it is not easy to detect if the template would have changed, therefore always report a change here. + result['changed'] = True + else: + # if the previous parsed template data is not equal to the updated one, this has changed + result['changed'] = template.TEMPLATE != result['template'] + + return result + + def delete_template(self, template): + if not template: + return {'changed': False} + + if not self.module.check_mode: + self.one.vn.delete(template.ID) + + return {'changed': True} + + +def main(): + NetworksModule().run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/openbsd_pkg.py b/plugins/modules/openbsd_pkg.py index c831136110..69ac7bff8e 100644 --- a/plugins/modules/openbsd_pkg.py +++ b/plugins/modules/openbsd_pkg.py @@ -24,7 +24,10 @@ attributes: check_mode: support: full diff_mode: - support: none + support: partial + version_added: 9.1.0 + details: + - Only works when check mode is not enabled. options: name: description: @@ -159,6 +162,20 @@ def execute_command(cmd, module): return module.run_command(cmd_args, environ_update={'TERM': 'dumb'}) +def get_all_installed(module): + """ + Get all installed packaged. Used to support diff mode + """ + command = 'pkg_info -Iq' + + rc, stdout, stderr = execute_command(command, module) + + if stderr: + module.fail_json(msg="failed in get_all_installed(): %s" % stderr) + + return stdout + + # Function used to find out if a package is currently installed. def get_package_state(names, pkg_spec, module): info_cmd = 'pkg_info -Iq' @@ -573,10 +590,13 @@ def main(): result['name'] = name result['state'] = state result['build'] = build + result['diff'] = {} # The data structure used to keep track of package information. pkg_spec = {} + new_package_list = original_package_list = get_all_installed(module) + if build is True: if not os.path.isdir(ports_dir): module.fail_json(msg="the ports source directory %s does not exist" % (ports_dir)) @@ -661,6 +681,10 @@ def main(): result['changed'] = combined_changed + if result['changed'] and not module.check_mode: + new_package_list = get_all_installed(module) + result['diff'] = dict(before=original_package_list, after=new_package_list) + module.exit_json(**result) diff --git a/plugins/modules/opkg.py b/plugins/modules/opkg.py index 757c88c5de..2f9794ab86 100644 --- a/plugins/modules/opkg.py +++ b/plugins/modules/opkg.py @@ -127,6 +127,7 @@ class Opkg(StateModuleHelper): executable=dict(type="path"), ), ) + use_old_vardict = False def __init_module__(self): self.vars.set("install_c", 0, output=False, change=True) diff --git a/plugins/modules/osx_defaults.py b/plugins/modules/osx_defaults.py index 336e953320..db5d889a37 100644 --- a/plugins/modules/osx_defaults.py +++ b/plugins/modules/osx_defaults.py @@ -50,6 +50,13 @@ options: type: str choices: [ array, bool, boolean, date, float, int, integer, string ] default: string + check_type: + description: + - Checks if the type of the provided O(value) matches the type of an existing default. + - If the types do not match, raises an error. + type: bool + default: true + version_added: 8.6.0 array_add: description: - Add new elements to the array for a key which has an array as its value. @@ -158,6 +165,7 @@ class OSXDefaults(object): self.domain = module.params['domain'] self.host = module.params['host'] self.key = module.params['key'] + self.check_type = module.params['check_type'] self.type = module.params['type'] self.array_add = module.params['array_add'] self.value = module.params['value'] @@ -349,10 +357,11 @@ class OSXDefaults(object): self.delete() return True - # There is a type mismatch! Given type does not match the type in defaults - value_type = type(self.value) - if self.current_value is not None and not isinstance(self.current_value, value_type): - raise OSXDefaultsException("Type mismatch. Type in defaults: %s" % type(self.current_value).__name__) + # Check if there is a type mismatch, e.g. given type does not match the type in defaults + if self.check_type: + value_type = type(self.value) + if self.current_value is not None and not isinstance(self.current_value, value_type): + raise OSXDefaultsException("Type mismatch. Type in defaults: %s" % type(self.current_value).__name__) # Current value matches the given value. Nothing need to be done. Arrays need extra care if self.type == "array" and self.current_value is not None and not self.array_add and \ @@ -383,6 +392,7 @@ def main(): domain=dict(type='str', default='NSGlobalDomain'), host=dict(type='str'), key=dict(type='str', no_log=False), + check_type=dict(type='bool', default=True), type=dict(type='str', default='string', choices=['array', 'bool', 'boolean', 'date', 'float', 'int', 'integer', 'string']), array_add=dict(type='bool', default=False), value=dict(type='raw'), diff --git a/plugins/modules/pacman.py b/plugins/modules/pacman.py index 7f67b91039..f13bde317c 100644 --- a/plugins/modules/pacman.py +++ b/plugins/modules/pacman.py @@ -367,8 +367,9 @@ class Pacman(object): self.install_packages(pkgs) self.success() - # This shouldn't happen... - self.fail("This is a bug") + # This happens if an empty list has been provided for name + self.add_exit_infos(msg='Nothing to do') + self.success() def install_packages(self, pkgs): pkgs_to_install = [] diff --git a/plugins/modules/pagerduty.py b/plugins/modules/pagerduty.py index 596c4f4da1..853bd6d797 100644 --- a/plugins/modules/pagerduty.py +++ b/plugins/modules/pagerduty.py @@ -151,6 +151,10 @@ import json from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import fetch_url +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + class PagerDutyRequest(object): def __init__(self, module, name, user, token): @@ -206,9 +210,9 @@ class PagerDutyRequest(object): return [{'id': service, 'type': 'service_reference'}] def _compute_start_end_time(self, hours, minutes): - now = datetime.datetime.utcnow() - later = now + datetime.timedelta(hours=int(hours), minutes=int(minutes)) - start = now.strftime("%Y-%m-%dT%H:%M:%SZ") + now_t = now() + later = now_t + datetime.timedelta(hours=int(hours), minutes=int(minutes)) + start = now_t.strftime("%Y-%m-%dT%H:%M:%SZ") end = later.strftime("%Y-%m-%dT%H:%M:%SZ") return start, end diff --git a/plugins/modules/pagerduty_change.py b/plugins/modules/pagerduty_change.py index 1a1e50dcf7..acd31fb447 100644 --- a/plugins/modules/pagerduty_change.py +++ b/plugins/modules/pagerduty_change.py @@ -110,7 +110,10 @@ EXAMPLES = ''' from ansible.module_utils.urls import fetch_url from ansible.module_utils.basic import AnsibleModule -from datetime import datetime + +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) def main(): @@ -161,8 +164,7 @@ def main(): if module.params['environment']: custom_details['environment'] = module.params['environment'] - now = datetime.utcnow() - timestamp = now.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + timestamp = now().strftime("%Y-%m-%dT%H:%M:%S.%fZ") payload = { 'summary': module.params['summary'], diff --git a/plugins/modules/parted.py b/plugins/modules/parted.py index 382e47a475..b3616a8ecd 100644 --- a/plugins/modules/parted.py +++ b/plugins/modules/parted.py @@ -480,12 +480,12 @@ def get_device_info(device, unit): if label_needed: return get_unlabeled_device_info(device, unit) - command = "%s -s -m %s -- unit '%s' print" % (parted_exec, device, unit) + command = [parted_exec, "-s", "-m", device, "--", "unit", unit, "print"] rc, out, err = module.run_command(command) if rc != 0 and 'unrecognised disk label' not in err: module.fail_json(msg=( "Error while getting device information with parted " - "script: '%s'" % command), + "script: '%s'" % " ".join(command)), rc=rc, out=out, err=err ) @@ -506,7 +506,7 @@ def check_parted_label(device): return False # Older parted versions return a message in the stdout and RC > 0. - rc, out, err = module.run_command("%s -s -m %s print" % (parted_exec, device)) + rc, out, err = module.run_command([parted_exec, "-s", "-m", device, "print"]) if rc != 0 and 'unrecognised disk label' in out.lower(): return True @@ -546,7 +546,7 @@ def parted_version(): """ global module, parted_exec # pylint: disable=global-variable-not-assigned - rc, out, err = module.run_command("%s --version" % parted_exec) + rc, out, err = module.run_command([parted_exec, "--version"]) if rc != 0: module.fail_json( msg="Failed to get parted version.", rc=rc, out=out, err=err @@ -580,6 +580,7 @@ def parted(script, device, align): script_option = '-s' if script and not module.check_mode: + # TODO: convert run_comand() argument to list! command = "%s %s -m %s %s -- %s" % (parted_exec, script_option, align_option, device, script) rc, out, err = module.run_command(command) diff --git a/plugins/modules/pids.py b/plugins/modules/pids.py index 590f1e85a5..99b52ef1dd 100644 --- a/plugins/modules/pids.py +++ b/plugins/modules/pids.py @@ -111,7 +111,7 @@ class PSAdapter(object): attributes['cmdline'] and compare_lower(attributes['cmdline'][0], name)) def _get_proc_attributes(self, proc, *attributes): - return dict((attribute, self._get_attribute_from_proc(proc, attribute)) for attribute in attributes) + return {attribute: self._get_attribute_from_proc(proc, attribute) for attribute in attributes} @staticmethod @abc.abstractmethod diff --git a/plugins/modules/pip_package_info.py b/plugins/modules/pip_package_info.py index 6aea178cec..f7354e3678 100644 --- a/plugins/modules/pip_package_info.py +++ b/plugins/modules/pip_package_info.py @@ -27,6 +27,7 @@ options: type: list elements: path requirements: + - pip >= 20.3b1 (necessary for the C(--format) option) - The requested pip executables must be installed on the target. author: - Matthew Jones (@matburt) diff --git a/plugins/modules/pipx.py b/plugins/modules/pipx.py index 705cc71a77..4793dd49ea 100644 --- a/plugins/modules/pipx.py +++ b/plugins/modules/pipx.py @@ -26,13 +26,31 @@ attributes: options: state: type: str - choices: [present, absent, install, uninstall, uninstall_all, inject, upgrade, upgrade_all, reinstall, reinstall_all, latest] + choices: + - present + - absent + - install + - install_all + - uninstall + - uninstall_all + - inject + - uninject + - upgrade + - upgrade_shared + - upgrade_all + - reinstall + - reinstall_all + - latest + - pin + - unpin default: install description: - Desired state for the application. - The states V(present) and V(absent) are aliases to V(install) and V(uninstall), respectively. - The state V(latest) is equivalent to executing the task twice, with state V(install) and then V(upgrade). It was added in community.general 5.5.0. + - The states V(install_all), V(uninject), V(upgrade_shared), V(pin) and V(unpin) are only available in C(pipx>=1.6.0), + make sure to have a compatible version when using this option. These states have been added in community.general 9.4.0. name: type: str description: @@ -114,14 +132,35 @@ options: - Arbitrary arguments to pass directly to C(pip). type: str version_added: 4.6.0 + suffix: + description: + - Optional suffix for virtual environment and executable names. + - "B(Warning:) C(pipx) documentation states this is an B(experimental) feature subject to change." + type: str + version_added: 9.3.0 + global: + description: + - The module will pass the C(--global) argument to C(pipx), to execute actions in global scope. + - The C(--global) is only available in C(pipx>=1.6.0), so make sure to have a compatible version when using this option. + Moreover, a nasty bug with C(--global) was fixed in C(pipx==1.7.0), so it is strongly recommended you used that version or newer. + type: bool + default: false + version_added: 9.4.0 + spec_metadata: + description: + - Spec metadata file for O(state=install_all). + - This content of the file is usually generated with C(pipx list --json), and it can be obtained with M(community.general.pipx_info) + with O(community.general.pipx_info#module:include_raw=true) and obtaining the content from the RV(community.general.pipx_info#module:raw_output). + type: path + version_added: 9.4.0 notes: + - This module requires C(pipx) version 0.16.2.1 or above. From community.general 11.0.0 onwards, the module will require C(pipx>=1.7.0). + - Please note that C(pipx) requires Python 3.6 or above. - This module does not install the C(pipx) python package, however that can be easily done with the module M(ansible.builtin.pip). - This module does not require C(pipx) to be in the shell C(PATH), but it must be loadable by Python as a module. - > This module will honor C(pipx) environment variables such as but not limited to C(PIPX_HOME) and C(PIPX_BIN_DIR) passed using the R(environment Ansible keyword, playbooks_environment). - - This module requires C(pipx) version 0.16.2.1 or above. - - Please note that C(pipx) requires Python 3.6 or above. - > This first implementation does not verify whether a specified version constraint has been installed or not. Hence, when using version operators, C(pipx) module will always try to execute the operation, @@ -157,6 +196,17 @@ EXAMPLES = ''' community.general.pipx: name: pycowsay state: absent + +- name: Install multiple packages from list + vars: + pipx_packages: + - pycowsay + - black + - tox + community.general.pipx: + name: "{{ item }}" + state: latest + with_items: "{{ pipx_packages }}" ''' @@ -168,39 +218,57 @@ from ansible_collections.community.general.plugins.module_utils.pipx import pipx from ansible.module_utils.facts.compat import ansible_facts +def _make_name(name, suffix): + return name if suffix is None else "{0}{1}".format(name, suffix) + + class PipX(StateModuleHelper): output_params = ['name', 'source', 'index_url', 'force', 'installdeps'] + argument_spec = dict( + state=dict(type='str', default='install', + choices=[ + 'present', 'absent', 'install', 'install_all', 'uninstall', 'uninstall_all', 'inject', 'uninject', + 'upgrade', 'upgrade_shared', 'upgrade_all', 'reinstall', 'reinstall_all', 'latest', 'pin', 'unpin', + ]), + name=dict(type='str'), + source=dict(type='str'), + install_apps=dict(type='bool', default=False), + install_deps=dict(type='bool', default=False), + inject_packages=dict(type='list', elements='str'), + force=dict(type='bool', default=False), + include_injected=dict(type='bool', default=False), + index_url=dict(type='str'), + python=dict(type='str'), + system_site_packages=dict(type='bool', default=False), + executable=dict(type='path'), + editable=dict(type='bool', default=False), + pip_args=dict(type='str'), + suffix=dict(type='str'), + spec_metadata=dict(type='path'), + ) + argument_spec["global"] = dict(type='bool', default=False) + module = dict( - argument_spec=dict( - state=dict(type='str', default='install', - choices=['present', 'absent', 'install', 'uninstall', 'uninstall_all', - 'inject', 'upgrade', 'upgrade_all', 'reinstall', 'reinstall_all', 'latest']), - name=dict(type='str'), - source=dict(type='str'), - install_apps=dict(type='bool', default=False), - install_deps=dict(type='bool', default=False), - inject_packages=dict(type='list', elements='str'), - force=dict(type='bool', default=False), - include_injected=dict(type='bool', default=False), - index_url=dict(type='str'), - python=dict(type='str'), - system_site_packages=dict(type='bool', default=False), - executable=dict(type='path'), - editable=dict(type='bool', default=False), - pip_args=dict(type='str'), - ), + argument_spec=argument_spec, required_if=[ ('state', 'present', ['name']), ('state', 'install', ['name']), + ('state', 'install_all', ['spec_metadata']), ('state', 'absent', ['name']), ('state', 'uninstall', ['name']), ('state', 'upgrade', ['name']), ('state', 'reinstall', ['name']), ('state', 'latest', ['name']), ('state', 'inject', ['name', 'inject_packages']), + ('state', 'pin', ['name']), + ('state', 'unpin', ['name']), ], + required_by=dict( + suffix="name", + ), supports_check_mode=True, ) + use_old_vardict = False def _retrieve_installed(self): def process_list(rc, out, err): @@ -212,18 +280,17 @@ class PipX(StateModuleHelper): for venv_name, venv in raw_data['venvs'].items(): results[venv_name] = { 'version': venv['metadata']['main_package']['package_version'], - 'injected': dict( - (k, v['package_version']) for k, v in venv['metadata']['injected_packages'].items() - ), + 'injected': {k: v['package_version'] for k, v in venv['metadata']['injected_packages'].items()}, } return results installed = self.runner('_list', output_process=process_list).run(_list=1) if self.vars.name is not None: - app_list = installed.get(self.vars.name) + name = _make_name(self.vars.name, self.vars.suffix) + app_list = installed.get(name) if app_list: - return {self.vars.name: app_list} + return {name: app_list} else: return {} @@ -246,74 +313,98 @@ class PipX(StateModuleHelper): self.vars.stdout = ctx.results_out self.vars.stderr = ctx.results_err self.vars.cmd = ctx.cmd - if self.verbosity >= 4: - self.vars.run_info = ctx.run_info + self.vars.set('run_info', ctx.run_info, verbosity=4) def state_install(self): if not self.vars.application or self.vars.force: self.changed = True - with self.runner('state index_url install_deps force python system_site_packages editable pip_args name_source', check_mode_skip=True) as ctx: + args_order = 'state global index_url install_deps force python system_site_packages editable pip_args suffix name_source' + with self.runner(args_order, check_mode_skip=True) as ctx: ctx.run(name_source=[self.vars.name, self.vars.source]) self._capture_results(ctx) state_present = state_install + def state_install_all(self): + self.changed = True + with self.runner('state global index_url force python system_site_packages editable pip_args spec_metadata', check_mode_skip=True) as ctx: + ctx.run(name_source=[self.vars.name, self.vars.source]) + self._capture_results(ctx) + def state_upgrade(self): + name = _make_name(self.vars.name, self.vars.suffix) if not self.vars.application: - self.do_raise("Trying to upgrade a non-existent application: {0}".format(self.vars.name)) + self.do_raise("Trying to upgrade a non-existent application: {0}".format(name)) if self.vars.force: self.changed = True - with self.runner('state include_injected index_url force editable pip_args name', check_mode_skip=True) as ctx: - ctx.run() + with self.runner('state global include_injected index_url force editable pip_args name', check_mode_skip=True) as ctx: + ctx.run(name=name) self._capture_results(ctx) def state_uninstall(self): if self.vars.application: - with self.runner('state name', check_mode_skip=True) as ctx: - ctx.run() + name = _make_name(self.vars.name, self.vars.suffix) + with self.runner('state global name', check_mode_skip=True) as ctx: + ctx.run(name=name) self._capture_results(ctx) state_absent = state_uninstall def state_reinstall(self): + name = _make_name(self.vars.name, self.vars.suffix) if not self.vars.application: - self.do_raise("Trying to reinstall a non-existent application: {0}".format(self.vars.name)) + self.do_raise("Trying to reinstall a non-existent application: {0}".format(name)) self.changed = True - with self.runner('state name python', check_mode_skip=True) as ctx: - ctx.run() + with self.runner('state global name python', check_mode_skip=True) as ctx: + ctx.run(name=name) self._capture_results(ctx) def state_inject(self): + name = _make_name(self.vars.name, self.vars.suffix) if not self.vars.application: - self.do_raise("Trying to inject packages into a non-existent application: {0}".format(self.vars.name)) + self.do_raise("Trying to inject packages into a non-existent application: {0}".format(name)) if self.vars.force: self.changed = True - with self.runner('state index_url install_apps install_deps force editable pip_args name inject_packages', check_mode_skip=True) as ctx: - ctx.run() + with self.runner('state global index_url install_apps install_deps force editable pip_args name inject_packages', check_mode_skip=True) as ctx: + ctx.run(name=name) + self._capture_results(ctx) + + def state_uninject(self): + name = _make_name(self.vars.name, self.vars.suffix) + if not self.vars.application: + self.do_raise("Trying to uninject packages into a non-existent application: {0}".format(name)) + with self.runner('state global name inject_packages', check_mode_skip=True) as ctx: + ctx.run(name=name) self._capture_results(ctx) def state_uninstall_all(self): - with self.runner('state', check_mode_skip=True) as ctx: + with self.runner('state global', check_mode_skip=True) as ctx: ctx.run() self._capture_results(ctx) def state_reinstall_all(self): - with self.runner('state python', check_mode_skip=True) as ctx: + with self.runner('state global python', check_mode_skip=True) as ctx: ctx.run() self._capture_results(ctx) def state_upgrade_all(self): if self.vars.force: self.changed = True - with self.runner('state include_injected force', check_mode_skip=True) as ctx: + with self.runner('state global include_injected force', check_mode_skip=True) as ctx: + ctx.run() + self._capture_results(ctx) + + def state_upgrade_shared(self): + with self.runner('state global pip_args', check_mode_skip=True) as ctx: ctx.run() self._capture_results(ctx) def state_latest(self): if not self.vars.application or self.vars.force: self.changed = True - with self.runner('state index_url install_deps force python system_site_packages editable pip_args name_source', check_mode_skip=True) as ctx: + args_order = 'state index_url install_deps force python system_site_packages editable pip_args suffix name_source' + with self.runner(args_order, check_mode_skip=True) as ctx: ctx.run(state='install', name_source=[self.vars.name, self.vars.source]) self._capture_results(ctx) @@ -321,6 +412,16 @@ class PipX(StateModuleHelper): ctx.run(state='upgrade') self._capture_results(ctx) + def state_pin(self): + with self.runner('state global name', check_mode_skip=True) as ctx: + ctx.run() + self._capture_results(ctx) + + def state_unpin(self): + with self.runner('state global name', check_mode_skip=True) as ctx: + ctx.run() + self._capture_results(ctx) + def main(): PipX.execute() diff --git a/plugins/modules/pipx_info.py b/plugins/modules/pipx_info.py index 34f9681b06..816729f9a6 100644 --- a/plugins/modules/pipx_info.py +++ b/plugins/modules/pipx_info.py @@ -47,14 +47,22 @@ options: If not specified, the module will use C(python -m pipx) to run the tool, using the same Python interpreter as ansible itself. type: path + global: + description: + - The module will pass the C(--global) argument to C(pipx), to execute actions in global scope. + - The C(--global) is only available in C(pipx>=1.6.0), so make sure to have a compatible version when using this option. + Moreover, a nasty bug with C(--global) was fixed in C(pipx==1.7.0), so it is strongly recommended you used that version or newer. + type: bool + default: false + version_added: 9.3.0 notes: + - This module requires C(pipx) version 0.16.2.1 or above. From community.general 11.0.0 onwards, the module will require C(pipx>=1.7.0). + - Please note that C(pipx) requires Python 3.6 or above. - This module does not install the C(pipx) python package, however that can be easily done with the module M(ansible.builtin.pip). - This module does not require C(pipx) to be in the shell C(PATH), but it must be loadable by Python as a module. - > This module will honor C(pipx) environment variables such as but not limited to E(PIPX_HOME) and E(PIPX_BIN_DIR) passed using the R(environment Ansible keyword, playbooks_environment). - - This module requires C(pipx) version 0.16.2.1 or above. - - Please note that C(pipx) requires Python 3.6 or above. - See also the C(pipx) documentation at U(https://pypa.github.io/pipx/). author: - "Alexei Znamensky (@russoz)" @@ -140,16 +148,19 @@ from ansible.module_utils.facts.compat import ansible_facts class PipXInfo(ModuleHelper): output_params = ['name'] + argument_spec = dict( + name=dict(type='str'), + include_deps=dict(type='bool', default=False), + include_injected=dict(type='bool', default=False), + include_raw=dict(type='bool', default=False), + executable=dict(type='path'), + ) + argument_spec["global"] = dict(type='bool', default=False) module = dict( - argument_spec=dict( - name=dict(type='str'), - include_deps=dict(type='bool', default=False), - include_injected=dict(type='bool', default=False), - include_raw=dict(type='bool', default=False), - executable=dict(type='path'), - ), + argument_spec=argument_spec, supports_check_mode=True, ) + use_old_vardict = False def __init_module__(self): if self.vars.executable: @@ -185,16 +196,14 @@ class PipXInfo(ModuleHelper): 'version': venv['metadata']['main_package']['package_version'] } if self.vars.include_injected: - entry['injected'] = dict( - (k, v['package_version']) for k, v in venv['metadata']['injected_packages'].items() - ) + entry['injected'] = {k: v['package_version'] for k, v in venv['metadata']['injected_packages'].items()} if self.vars.include_deps: entry['dependencies'] = list(venv['metadata']['main_package']['app_paths_of_dependencies']) results.append(entry) return results - with self.runner('_list', output_process=process_list) as ctx: + with self.runner('_list global', output_process=process_list) as ctx: self.vars.application = ctx.run(_list=1) self._capture_results(ctx) diff --git a/plugins/modules/pkg5.py b/plugins/modules/pkg5.py index c4aace9f28..08fa9272f7 100644 --- a/plugins/modules/pkg5.py +++ b/plugins/modules/pkg5.py @@ -54,6 +54,12 @@ options: - Refresh publishers before execution. type: bool default: true + verbose: + description: + - Set to V(true) to disable quiet execution. + type: bool + default: false + version_added: 9.0.0 ''' EXAMPLES = ''' - name: Install Vim @@ -90,6 +96,7 @@ def main(): accept_licenses=dict(type='bool', default=False, aliases=['accept', 'accept_licences']), be_name=dict(type='str'), refresh=dict(type='bool', default=True), + verbose=dict(type='bool', default=False), ), supports_check_mode=True, ) @@ -156,9 +163,15 @@ def ensure(module, state, packages, params): else: no_refresh = ['--no-refresh'] + if params['verbose']: + verbosity = [] + else: + verbosity = ['-q'] + to_modify = list(filter(behaviour[state]['filter'], packages)) if to_modify: - rc, out, err = module.run_command(['pkg', behaviour[state]['subcommand']] + dry_run + accept_licenses + beadm + no_refresh + ['-q', '--'] + to_modify) + rc, out, err = module.run_command( + ['pkg', behaviour[state]['subcommand']] + dry_run + accept_licenses + beadm + no_refresh + verbosity + ['--'] + to_modify) response['rc'] = rc response['results'].append(out) response['msg'] += err diff --git a/plugins/modules/pkg5_publisher.py b/plugins/modules/pkg5_publisher.py index 9d1b381385..6d07e455f4 100644 --- a/plugins/modules/pkg5_publisher.py +++ b/plugins/modules/pkg5_publisher.py @@ -183,9 +183,7 @@ def get_publishers(module): name = values['publisher'] if name not in publishers: - publishers[name] = dict( - (k, values[k]) for k in ['sticky', 'enabled'] - ) + publishers[name] = {k: values[k] for k in ['sticky', 'enabled']} publishers[name]['origin'] = [] publishers[name]['mirror'] = [] diff --git a/plugins/modules/pkgin.py b/plugins/modules/pkgin.py index 5b2e478b8c..8b29655d37 100644 --- a/plugins/modules/pkgin.py +++ b/plugins/modules/pkgin.py @@ -145,18 +145,18 @@ def query_package(module, name): """ # test whether '-p' (parsable) flag is supported. - rc, out, err = module.run_command("%s -p -v" % PKGIN_PATH) + rc, out, err = module.run_command([PKGIN_PATH, "-p", "-v"]) if rc == 0: - pflag = '-p' + pflag = ['-p'] splitchar = ';' else: - pflag = '' + pflag = [] splitchar = ' ' # Use "pkgin search" to find the package. The regular expression will # only match on the complete name. - rc, out, err = module.run_command("%s %s search \"^%s$\"" % (PKGIN_PATH, pflag, name)) + rc, out, err = module.run_command([PKGIN_PATH] + pflag + ["search", "^%s$" % name]) # rc will not be 0 unless the search was a success if rc == 0: @@ -234,22 +234,19 @@ def format_pkgin_command(module, command, package=None): # an empty string. Some commands (e.g. 'update') will ignore extra # arguments, however this behaviour cannot be relied on for others. if package is None: - package = "" + packages = [] + else: + packages = [package] if module.params["force"]: - force = "-F" + force = ["-F"] else: - force = "" - - vars = {"pkgin": PKGIN_PATH, - "command": command, - "package": package, - "force": force} + force = [] if module.check_mode: - return "%(pkgin)s -n %(command)s %(package)s" % vars + return [PKGIN_PATH, "-n", command] + packages else: - return "%(pkgin)s -y %(force)s %(command)s %(package)s" % vars + return [PKGIN_PATH, "-y"] + force + [command] + packages def remove_packages(module, packages): diff --git a/plugins/modules/pkgng.py b/plugins/modules/pkgng.py index 88c9b8e3b9..7a04ee3a6e 100644 --- a/plugins/modules/pkgng.py +++ b/plugins/modules/pkgng.py @@ -100,6 +100,13 @@ options: type: bool default: false version_added: 1.3.0 + use_globs: + description: + - Treat the package names as shell glob patterns. + required: false + type: bool + default: true + version_added: 9.3.0 author: "bleader (@bleader)" notes: - When using pkgsite, be careful that already in cache packages won't be downloaded again. @@ -127,7 +134,6 @@ EXAMPLES = ''' - bar state: absent -# "latest" support added in 2.7 - name: Upgrade package baz community.general.pkgng: name: baz @@ -137,6 +143,12 @@ EXAMPLES = ''' community.general.pkgng: name: "*" state: latest + +- name: Upgrade foo/bar + community.general.pkgng: + name: foo/bar + state: latest + use_globs: false ''' @@ -147,7 +159,7 @@ from ansible.module_utils.basic import AnsibleModule def query_package(module, run_pkgng, name): - rc, out, err = run_pkgng('info', '-g', '-e', name) + rc, out, err = run_pkgng('info', '-e', name) return rc == 0 @@ -157,7 +169,7 @@ def query_update(module, run_pkgng, name): # Check to see if a package upgrade is available. # rc = 0, no updates available or package not installed # rc = 1, updates available - rc, out, err = run_pkgng('upgrade', '-g', '-n', name) + rc, out, err = run_pkgng('upgrade', '-n', name) return rc == 1 @@ -260,7 +272,7 @@ def install_packages(module, run_pkgng, packages, cached, state): action_count[action] += len(package_list) continue - pkgng_args = [action, '-g', '-U', '-y'] + package_list + pkgng_args = [action, '-U', '-y'] + package_list rc, out, err = run_pkgng(*pkgng_args) stdout += out stderr += err @@ -290,7 +302,7 @@ def install_packages(module, run_pkgng, packages, cached, state): def annotation_query(module, run_pkgng, package, tag): - rc, out, err = run_pkgng('info', '-g', '-A', package) + rc, out, err = run_pkgng('info', '-A', package) match = re.search(r'^\s*(?P%s)\s*:\s*(?P\w+)' % tag, out, flags=re.MULTILINE) if match: return match.group('value') @@ -425,7 +437,9 @@ def main(): rootdir=dict(required=False, type='path'), chroot=dict(required=False, type='path'), jail=dict(required=False, type='str'), - autoremove=dict(default=False, type='bool')), + autoremove=dict(default=False, type='bool'), + use_globs=dict(default=True, required=False, type='bool'), + ), supports_check_mode=True, mutually_exclusive=[["rootdir", "chroot", "jail"]]) @@ -466,6 +480,9 @@ def main(): def run_pkgng(action, *args, **kwargs): cmd = [pkgng_path, dir_arg, action] + if p["use_globs"] and action in ('info', 'install', 'upgrade',): + args = ('-g',) + args + pkgng_env = {'BATCH': 'yes'} if p["ignore_osver"]: diff --git a/plugins/modules/portage.py b/plugins/modules/portage.py index 112f6d2d7c..8ae8efb087 100644 --- a/plugins/modules/portage.py +++ b/plugins/modules/portage.py @@ -121,6 +121,14 @@ options: type: bool default: false + select: + description: + - If set to V(true), explicitely add the package to the world file. + - Please note that this option is not used for idempotency, it is only used + when actually installing a package. + type: bool + version_added: 8.6.0 + sync: description: - Sync package repositories first @@ -374,6 +382,7 @@ def emerge_packages(module, packages): 'loadavg': '--load-average', 'backtrack': '--backtrack', 'withbdeps': '--with-bdeps', + 'select': '--select', } for flag, arg in emerge_flags.items(): @@ -523,6 +532,7 @@ def main(): nodeps=dict(default=False, type='bool'), onlydeps=dict(default=False, type='bool'), depclean=dict(default=False, type='bool'), + select=dict(default=None, type='bool'), quiet=dict(default=False, type='bool'), verbose=dict(default=False, type='bool'), sync=dict(default=None, choices=['yes', 'web', 'no']), @@ -543,6 +553,7 @@ def main(): ['quiet', 'verbose'], ['quietbuild', 'verbose'], ['quietfail', 'verbose'], + ['oneshot', 'select'], ], supports_check_mode=True, ) diff --git a/plugins/modules/portinstall.py b/plugins/modules/portinstall.py index e263b71813..59dafb1eb8 100644 --- a/plugins/modules/portinstall.py +++ b/plugins/modules/portinstall.py @@ -79,12 +79,13 @@ def query_package(module, name): if pkg_info_path: pkgng = False pkg_glob_path = module.get_bin_path('pkg_glob', True) + # TODO: convert run_comand() argument to list! rc, out, err = module.run_command("%s -e `pkg_glob %s`" % (pkg_info_path, shlex_quote(name)), use_unsafe_shell=True) + pkg_info_path = [pkg_info_path] else: pkgng = True - pkg_info_path = module.get_bin_path('pkg', True) - pkg_info_path = pkg_info_path + " info" - rc, out, err = module.run_command("%s %s" % (pkg_info_path, name)) + pkg_info_path = [module.get_bin_path('pkg', True), "info"] + rc, out, err = module.run_command(pkg_info_path + [name]) found = rc == 0 @@ -94,10 +95,7 @@ def query_package(module, name): # some package is installed name_without_digits = re.sub('[0-9]', '', name) if name != name_without_digits: - if pkgng: - rc, out, err = module.run_command("%s %s" % (pkg_info_path, name_without_digits)) - else: - rc, out, err = module.run_command("%s %s" % (pkg_info_path, name_without_digits)) + rc, out, err = module.run_command(pkg_info_path + [name_without_digits]) found = rc == 0 @@ -107,13 +105,13 @@ def query_package(module, name): def matching_packages(module, name): ports_glob_path = module.get_bin_path('ports_glob', True) - rc, out, err = module.run_command("%s %s" % (ports_glob_path, name)) + rc, out, err = module.run_command([ports_glob_path, name]) # counts the number of packages found occurrences = out.count('\n') if occurrences == 0: name_without_digits = re.sub('[0-9]', '', name) if name != name_without_digits: - rc, out, err = module.run_command("%s %s" % (ports_glob_path, name_without_digits)) + rc, out, err = module.run_command([ports_glob_path, name_without_digits]) occurrences = out.count('\n') return occurrences @@ -135,10 +133,12 @@ def remove_packages(module, packages): if not query_package(module, package): continue + # TODO: convert run_comand() argument to list! rc, out, err = module.run_command("%s `%s %s`" % (pkg_delete_path, pkg_glob_path, shlex_quote(package)), use_unsafe_shell=True) if query_package(module, package): name_without_digits = re.sub('[0-9]', '', package) + # TODO: convert run_comand() argument to list! rc, out, err = module.run_command("%s `%s %s`" % (pkg_delete_path, pkg_glob_path, shlex_quote(name_without_digits)), use_unsafe_shell=True) @@ -163,13 +163,13 @@ def install_packages(module, packages, use_packages): if not portinstall_path: pkg_path = module.get_bin_path('pkg', False) if pkg_path: - module.run_command("pkg install -y portupgrade") + module.run_command([pkg_path, "install", "-y", "portupgrade"]) portinstall_path = module.get_bin_path('portinstall', True) if use_packages: - portinstall_params = "--use-packages" + portinstall_params = ["--use-packages"] else: - portinstall_params = "" + portinstall_params = [] for package in packages: if query_package(module, package): @@ -178,7 +178,7 @@ def install_packages(module, packages, use_packages): # TODO: check how many match matches = matching_packages(module, package) if matches == 1: - rc, out, err = module.run_command("%s --batch %s %s" % (portinstall_path, portinstall_params, package)) + rc, out, err = module.run_command([portinstall_path, "--batch"] + portinstall_params + [package]) if not query_package(module, package): module.fail_json(msg="failed to install %s: %s" % (package, out)) elif matches == 0: diff --git a/plugins/modules/proxmox.py b/plugins/modules/proxmox.py index 47f3faa4f2..52d5a849f3 100644 --- a/plugins/modules/proxmox.py +++ b/plugins/modules/proxmox.py @@ -15,12 +15,14 @@ short_description: Management of instances in Proxmox VE cluster description: - Allows you to create/delete/stop instances in Proxmox VE cluster. - The module automatically detects containerization type (lxc for PVE 4, openvz for older). - - Since community.general 4.0.0 on, there are no more default values, see O(proxmox_default_behavior). + - Since community.general 4.0.0 on, there are no more default values. attributes: check_mode: support: none diff_mode: support: none + action_group: + version_added: 9.0.0 options: password: description: @@ -47,28 +49,59 @@ options: comma-delimited list C([volume=] [,acl=<1|0>] [,mountoptions=] [,quota=<1|0>] [,replicate=<1|0>] [,ro=<1|0>] [,shared=<1|0>] [,size=])." - See U(https://pve.proxmox.com/wiki/Linux_Container) for a full description. - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(3). - - Should not be used in conjunction with O(storage). + - This option is mutually exclusive with O(storage) and O(disk_volume). type: str + disk_volume: + description: + - Specify a hash/dictionary of the C(rootfs) disk. + - See U(https://pve.proxmox.com/wiki/Linux_Container#pct_mount_points) for a full description. + - This option is mutually exclusive with O(storage) and O(disk). + type: dict + version_added: 9.2.0 + suboptions: + storage: + description: + - O(disk_volume.storage) is the storage identifier of the storage to use for the C(rootfs). + - Mutually exclusive with O(disk_volume.host_path). + type: str + volume: + description: + - O(disk_volume.volume) is the name of an existing volume. + - If not defined, the module will check if one exists. If not, a new volume will be created. + - If defined, the volume must exist under that name. + - Required only if O(disk_volume.storage) is defined and mutually exclusive with O(disk_volume.host_path). + type: str + size: + description: + - O(disk_volume.size) is the size of the storage to use. + - The size is given in GB. + - Required only if O(disk_volume.storage) is defined and mutually exclusive with O(disk_volume.host_path). + type: int + host_path: + description: + - O(disk_volume.host_path) defines a bind or device path on the PVE host to use for the C(rootfs). + - Mutually exclusive with O(disk_volume.storage), O(disk_volume.volume), and O(disk_volume.size). + type: path + options: + description: + - O(disk_volume.options) is a dict of extra options. + - The value of any given option must be a string, for example V("1"). + type: dict cores: description: - Specify number of cores per socket. - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(1). type: int cpus: description: - numbers of allocated cpus for instance - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(1). type: int memory: description: - memory size in MB for instance - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(512). type: int swap: description: - swap memory size in MB for instance - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(0). type: int netif: description: @@ -92,8 +125,56 @@ options: version_added: 8.5.0 mounts: description: - - specifies additional mounts (separate disks) for the container. As a hash/dictionary defining mount points + - Specifies additional mounts (separate disks) for the container. As a hash/dictionary defining mount points as strings. + - This Option is mutually exclusive with O(mount_volumes). type: dict + mount_volumes: + description: + - Specify additional mounts (separate disks) for the container. As a hash/dictionary defining mount points. + - See U(https://pve.proxmox.com/wiki/Linux_Container#pct_mount_points) for a full description. + - This Option is mutually exclusive with O(mounts). + type: list + elements: dict + version_added: 9.2.0 + suboptions: + id: + description: + - O(mount_volumes[].id) is the identifier of the mount point written as C(mp[n]). + type: str + required: true + storage: + description: + - O(mount_volumes[].storage) is the storage identifier of the storage to use. + - Mutually exclusive with O(mount_volumes[].host_path). + type: str + volume: + description: + - O(mount_volumes[].volume) is the name of an existing volume. + - If not defined, the module will check if one exists. If not, a new volume will be created. + - If defined, the volume must exist under that name. + - Required only if O(mount_volumes[].storage) is defined and mutually exclusive with O(mount_volumes[].host_path). + type: str + size: + description: + - O(mount_volumes[].size) is the size of the storage to use. + - The size is given in GB. + - Required only if O(mount_volumes[].storage) is defined and mutually exclusive with O(mount_volumes[].host_path). + type: int + host_path: + description: + - O(mount_volumes[].host_path) defines a bind or device path on the PVE host to use for the C(rootfs). + - Mutually exclusive with O(mount_volumes[].storage), O(mount_volumes[].volume), and O(mount_volumes[].size). + type: path + mountpoint: + description: + - O(mount_volumes[].mountpoint) is the mount point of the volume. + type: path + required: true + options: + description: + - O(mount_volumes[].options) is a dict of extra options. + - The value of any given option must be a string, for example V("1"). + type: dict ip_address: description: - specifies the address the container will be assigned @@ -101,12 +182,11 @@ options: onboot: description: - specifies whether a VM will be started during system bootup - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(false). type: bool storage: description: - - target storage - - Should not be used in conjunction with O(disk). + - Target storage. + - This Option is mutually exclusive with O(disk) and O(disk_volume). type: str default: 'local' ostype: @@ -120,7 +200,6 @@ options: cpuunits: description: - CPU weight for a VM - - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(1000). type: int nameserver: description: @@ -200,25 +279,6 @@ options: - The special value V(host) configures the same timezone used by Proxmox host. type: str version_added: '7.1.0' - proxmox_default_behavior: - description: - - As of community.general 4.0.0, various options no longer have default values. - These default values caused problems when users expected different behavior from Proxmox - by default or filled options which caused problems when set. - - The value V(compatibility) (default before community.general 4.0.0) will ensure that the default values - are used when the values are not explicitly specified by the user. The new default is V(no_defaults), - which makes sure these options have no defaults. - - This affects the O(disk), O(cores), O(cpus), O(memory), O(onboot), O(swap), and O(cpuunits) options. - - > - This parameter is now B(deprecated) and it will be removed in community.general 10.0.0. - By then, the module's behavior should be to not set default values, equivalent to V(no_defaults). - If a consistent set of defaults is needed, the playbook or role should be responsible for setting it. - type: str - default: no_defaults - choices: - - compatibility - - no_defaults - version_added: "1.3.0" clone: description: - ID of the container to be cloned. @@ -242,6 +302,7 @@ author: Sergei Antipov (@UnderGreen) seealso: - module: community.general.proxmox_vm_info extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.proxmox.selection - community.general.attributes @@ -271,6 +332,20 @@ EXAMPLES = r''' ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' disk: 'local-lvm:20' +- name: Create new container with minimal options specifying disk storage location and size via disk_volume + community.general.proxmox: + vmid: 100 + node: uk-mc02 + api_user: root@pam + api_password: 1q2w3e + api_host: node1 + password: 123456 + hostname: example.org + ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' + disk_volume: + storage: local + size: 20 + - name: Create new container with hookscript and description community.general.proxmox: vmid: 100 @@ -326,7 +401,8 @@ EXAMPLES = r''' password: 123456 hostname: example.org ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' - netif: '{"net0":"name=eth0,ip=dhcp,ip6=dhcp,bridge=vmbr0"}' + netif: + net0: "name=eth0,ip=dhcp,ip6=dhcp,bridge=vmbr0" - name: Create new container with minimal options defining network interface with static ip community.general.proxmox: @@ -338,7 +414,21 @@ EXAMPLES = r''' password: 123456 hostname: example.org ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' - netif: '{"net0":"name=eth0,gw=192.168.0.1,ip=192.168.0.2/24,bridge=vmbr0"}' + netif: + net0: "name=eth0,gw=192.168.0.1,ip=192.168.0.2/24,bridge=vmbr0" + +- name: Create new container with more options defining network interface with static ip4 and ip6 with vlan-tag and mtu + community.general.proxmox: + vmid: 100 + node: uk-mc02 + api_user: root@pam + api_password: 1q2w3e + api_host: node1 + password: 123456 + hostname: example.org + ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' + netif: + net0: "name=eth0,gw=192.168.0.1,ip=192.168.0.2/24,ip6=fe80::1227/64,gw6=fe80::1,bridge=vmbr0,firewall=1,tag=934,mtu=1500" - name: Create new container with minimal options defining a mount with 8GB community.general.proxmox: @@ -350,7 +440,24 @@ EXAMPLES = r''' password: 123456 hostname: example.org ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' - mounts: '{"mp0":"local:8,mp=/mnt/test/"}' + mounts: + mp0: "local:8,mp=/mnt/test/" + +- name: Create new container with minimal options defining a mount with 8GB using mount_volumes + community.general.proxmox: + vmid: 100 + node: uk-mc02 + api_user: root@pam + api_password: 1q2w3e + api_host: node1 + password: 123456 + hostname: example.org + ostemplate: 'local:vztmpl/ubuntu-14.04-x86_64.tar.gz' + mount_volumes: + - id: mp0 + storage: local + size: 8 + mountpoint: /mnt/test - name: Create new container with minimal options defining a cpu core limit community.general.proxmox: @@ -420,7 +527,8 @@ EXAMPLES = r''' api_user: root@pam api_password: 1q2w3e api_host: node1 - netif: '{"net0":"name=eth0,gw=192.168.0.1,ip=192.168.0.3/24,bridge=vmbr0"}' + netif: + net0: "name=eth0,gw=192.168.0.1,ip=192.168.0.3/24,bridge=vmbr0" update: true - name: Start container @@ -501,6 +609,7 @@ from ansible_collections.community.general.plugins.module_utils.version import L from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native + from ansible_collections.community.general.plugins.module_utils.proxmox import ( ansible_to_proxmox_bool, proxmox_auth_argument_spec, ProxmoxAnsible) @@ -524,6 +633,127 @@ class ProxmoxLxcAnsible(ProxmoxAnsible): msg="Updating configuration is only supported for LXC enabled proxmox clusters.", ) + def parse_disk_string(disk_string): + # Example strings: + # "acl=0,thin1:base-100-disk-1,size=8G" + # "thin1:10,backup=0" + # "local:20" + # "volume=local-lvm:base-100-disk-1,size=20G" + # "/mnt/bindmounts/shared,mp=/shared" + # "volume=/dev/USB01,mp=/mnt/usb01" + args = disk_string.split(",") + # If the volume is not explicitly defined but implicit by only passing a key, + # add the "volume=" key prefix for ease of parsing. + args = ["volume=" + arg if "=" not in arg else arg for arg in args] + # Then create a dictionary from the arguments + disk_kwargs = dict(map(lambda item: item.split("="), args)) + + VOLUME_PATTERN = r"""(?x) + (?:(?P[\w\-.]+): + (?:(?P\d+)| + (?P[^,\s]+)) + )| + (?P[^,\s]+) + """ + # DISCLAIMER: + # There are two things called a "volume": + # 1. The "volume" key which describes the storage volume, device or directory to mount into the container. + # 2. The storage volume of a storage-backed mount point in the PVE storage sub system. + # In this section, we parse the "volume" key and check which type of mount point we are dealing with. + pattern = re.compile(VOLUME_PATTERN) + match_dict = pattern.match(disk_kwargs.pop("volume")).groupdict() + match_dict = {k: v for k, v in match_dict.items() if v is not None} + + if "storage" in match_dict and "volume" in match_dict: + disk_kwargs["storage"] = match_dict["storage"] + disk_kwargs["volume"] = match_dict["volume"] + elif "storage" in match_dict and "size" in match_dict: + disk_kwargs["storage"] = match_dict["storage"] + disk_kwargs["size"] = match_dict["size"] + elif "host_path" in match_dict: + disk_kwargs["host_path"] = match_dict["host_path"] + + # Pattern matching only available in Python 3.10+ + # match match_dict: + # case {"storage": storage, "volume": volume}: + # disk_kwargs["storage"] = storage + # disk_kwargs["volume"] = volume + + # case {"storage": storage, "size": size}: + # disk_kwargs["storage"] = storage + # disk_kwargs["size"] = size + + # case {"host_path": host_path}: + # disk_kwargs["host_path"] = host_path + + return disk_kwargs + + def convert_mounts(mount_dict): + return_list = [] + for mount_key, mount_value in mount_dict.items(): + mount_config = parse_disk_string(mount_value) + return_list.append(dict(id=mount_key, **mount_config)) + + return return_list + + def build_volume( + key, + storage=None, + volume=None, + host_path=None, + size=None, + mountpoint=None, + options=None, + **kwargs + ): + if size is not None and isinstance(size, str): + size = size.strip("G") + # 1. Handle volume checks/creation + # 1.1 Check if defined volume exists + if volume is not None: + storage_content = self.get_storage_content(node, storage, vmid=vmid) + vol_ids = [vol["volid"] for vol in storage_content] + volid = "{storage}:{volume}".format(storage=storage, volume=volume) + if volid not in vol_ids: + self.module.fail_json( + changed=False, + msg="Storage {storage} does not contain volume {volume}".format( + storage=storage, + volume=volume, + ), + ) + vol_string = "{storage}:{volume},size={size}G".format( + storage=storage, volume=volume, size=size + ) + # 1.2 If volume not defined (but storage is), check if it exists + elif storage is not None: + api_node = self.proxmox_api.nodes( + node + ) # The node must exist, but not the LXC + try: + vol = api_node.lxc(vmid).get("config").get(key) + volume = parse_disk_string(vol).get("volume") + vol_string = "{storage}:{volume},size={size}G".format( + storage=storage, volume=volume, size=size + ) + + # If not, we have proxmox create one using the special syntax + except Exception: + vol_string = "{storage}:{size}".format(storage=storage, size=size) + else: + raise AssertionError('Internal error') + + # 1.3 If we have a host_path, we don't have storage, a volume, or a size + vol_string = ",".join( + [vol_string] + + ([] if host_path is None else [host_path]) + + ([] if mountpoint is None else ["mp={0}".format(mountpoint)]) + + ([] if options is None else ["{0}={1}".format(k, v) for k, v in options.items()]) + + ([] if not kwargs else ["{0}={1}".format(k, v) for k, v in kwargs.items()]) + ) + + return {key: vol_string} + # Version limited features minimum_version = {"tags": "6.1", "timezone": "6.3"} proxmox_node = self.proxmox_api.nodes(node) @@ -541,22 +771,29 @@ class ProxmoxLxcAnsible(ProxmoxAnsible): ) # Remove all empty kwarg entries - kwargs = dict((k, v) for k, v in kwargs.items() if v is not None) + kwargs = {key: val for key, val in kwargs.items() if val is not None} if cpus is not None: kwargs["cpulimit"] = cpus if disk is not None: - kwargs["rootfs"] = disk + kwargs["disk_volume"] = parse_disk_string(disk) + if "disk_volume" in kwargs: + disk_dict = build_volume(key="rootfs", **kwargs.pop("disk_volume")) + kwargs.update(disk_dict) if memory is not None: kwargs["memory"] = memory if swap is not None: kwargs["swap"] = swap if "netif" in kwargs: - kwargs.update(kwargs["netif"]) - del kwargs["netif"] + kwargs.update(kwargs.pop("netif")) if "mounts" in kwargs: - kwargs.update(kwargs["mounts"]) - del kwargs["mounts"] + kwargs["mount_volumes"] = convert_mounts(kwargs.pop("mounts")) + if "mount_volumes" in kwargs: + mounts_list = kwargs.pop("mount_volumes") + for mount_config in mounts_list: + key = mount_config.pop("id") + mount_dict = build_volume(key=key, **mount_config) + kwargs.update(mount_dict) # LXC tags are expected to be valid and presented as a comma/semi-colon delimited string if "tags" in kwargs: re_tag = re.compile(r"^[a-z0-9_][a-z0-9_\-\+\.]*$") @@ -605,7 +842,7 @@ class ProxmoxLxcAnsible(ProxmoxAnsible): proxmox_node = self.proxmox_api.nodes(node) # Remove all empty kwarg entries - kwargs = dict((k, v) for k, v in kwargs.items() if v is not None) + kwargs = {k: v for k, v in kwargs.items() if v is not None} pve_version = self.version() @@ -758,12 +995,53 @@ def main(): hostname=dict(), ostemplate=dict(), disk=dict(type='str'), + disk_volume=dict( + type="dict", + options=dict( + storage=dict(type="str"), + volume=dict(type="str"), + size=dict(type="int"), + host_path=dict(type="path"), + options=dict(type="dict"), + ), + required_together=[("storage", "size")], + required_by={ + "volume": ("storage", "size"), + }, + mutually_exclusive=[ + ("host_path", "storage"), + ("host_path", "volume"), + ("host_path", "size"), + ], + ), cores=dict(type='int'), cpus=dict(type='int'), memory=dict(type='int'), swap=dict(type='int'), netif=dict(type='dict'), mounts=dict(type='dict'), + mount_volumes=dict( + type="list", + elements="dict", + options=dict( + id=(dict(type="str", required=True)), + storage=dict(type="str"), + volume=dict(type="str"), + size=dict(type="int"), + host_path=dict(type="path"), + mountpoint=dict(type="path", required=True), + options=dict(type="dict"), + ), + required_together=[("storage", "size")], + required_by={ + "volume": ("storage", "size"), + }, + mutually_exclusive=[ + ("host_path", "storage"), + ("host_path", "volume"), + ("host_path", "size"), + ], + ), ip_address=dict(), ostype=dict(default='auto', choices=[ 'auto', 'debian', 'devuan', 'ubuntu', 'centos', 'fedora', 'opensuse', 'archlinux', 'alpine', 'gentoo', 'nixos', 'unmanaged' @@ -785,8 +1063,6 @@ def main(): description=dict(type='str'), hookscript=dict(type='str'), timezone=dict(type='str'), - proxmox_default_behavior=dict(type='str', default='no_defaults', choices=['compatibility', 'no_defaults'], - removed_in_version='9.0.0', removed_from_collection='community.general'), clone=dict(type='int'), clone_type=dict(default='opportunistic', choices=['full', 'linked', 'opportunistic']), tags=dict(type='list', elements='str') @@ -801,11 +1077,17 @@ def main(): # either clone a container or create a new one from a template file. ('state', 'present', ('clone', 'ostemplate', 'update'), True), ], - required_together=[ - ('api_token_id', 'api_token_secret') + required_together=[("api_token_id", "api_token_secret")], + required_one_of=[("api_password", "api_token_id")], + mutually_exclusive=[ + ( + "clone", + "ostemplate", + "update", + ), # Creating a new container is done either by cloning an existing one, or based on a template. + ("disk", "disk_volume", "storage"), + ("mounts", "mount_volumes"), ], - required_one_of=[('api_password', 'api_token_id')], - mutually_exclusive=[('clone', 'ostemplate', 'update')], # Creating a new container is done either by cloning an existing one, or based on a template. ) proxmox = ProxmoxLxcAnsible(module) @@ -827,20 +1109,6 @@ def main(): timeout = module.params['timeout'] clone = module.params['clone'] - if module.params['proxmox_default_behavior'] == 'compatibility': - old_default_values = dict( - disk="3", - cores=1, - cpus=1, - memory=512, - swap=0, - onboot=False, - cpuunits=1000, - ) - for param, value in old_default_values.items(): - if module.params[param] is None: - module.params[param] = value - # If vmid not set get the Next VM id from ProxmoxAPI # If hostname is set get the VM id from ProxmoxAPI if not vmid and state == 'present': @@ -860,7 +1128,9 @@ def main(): cores=module.params["cores"], hostname=module.params["hostname"], netif=module.params["netif"], + disk_volume=module.params["disk_volume"], mounts=module.params["mounts"], + mount_volumes=module.params["mount_volumes"], ip_address=module.params["ip_address"], onboot=ansible_to_proxmox_bool(module.params["onboot"]), cpuunits=module.params["cpuunits"], @@ -915,7 +1185,9 @@ def main(): hostname=module.params['hostname'], ostemplate=module.params['ostemplate'], netif=module.params['netif'], + disk_volume=module.params["disk_volume"], mounts=module.params['mounts'], + mount_volumes=module.params["mount_volumes"], ostype=module.params['ostype'], ip_address=module.params['ip_address'], onboot=ansible_to_proxmox_bool(module.params['onboot']), diff --git a/plugins/modules/proxmox_disk.py b/plugins/modules/proxmox_disk.py index 69a7300dfd..a4a9dd8791 100644 --- a/plugins/modules/proxmox_disk.py +++ b/plugins/modules/proxmox_disk.py @@ -21,6 +21,8 @@ attributes: support: none diff_mode: support: none + action_group: + version_added: 9.0.0 options: name: description: @@ -325,6 +327,7 @@ options: - The drive's worldwide name, encoded as 16 bytes hex string, prefixed by V(0x). type: str extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes ''' @@ -521,8 +524,11 @@ class ProxmoxDiskAnsible(ProxmoxAnsible): # - Remove not defined args # - Ensure True and False converted to int. # - Remove unnecessary parameters - params = dict((k, v) for k, v in self.module.params.items() if v is not None and k in self.create_update_fields) - params.update(dict((k, int(v)) for k, v in params.items() if isinstance(v, bool))) + params = { + k: int(v) if isinstance(v, bool) else v + for k, v in self.module.params.items() + if v is not None and k in self.create_update_fields + } return params def wait_till_complete_or_timeout(self, node_name, task_id): @@ -541,6 +547,7 @@ class ProxmoxDiskAnsible(ProxmoxAnsible): # NOOP return False, "Disk %s not found in VM %s and creation was disabled in parameters." % (disk, vmid) + timeout_str = "Reached timeout. Last line in task before timeout: %s" if (create == 'regular' and disk not in vm_config) or (create == 'forced'): # CREATE playbook_config = self.get_create_attributes() @@ -594,7 +601,7 @@ class ProxmoxDiskAnsible(ProxmoxAnsible): if iso_image is not None: playbook_config['volume'] = iso_image # Values in params are numbers, but strings are needed to compare with disk_config - playbook_config = dict((k, str(v)) for k, v in playbook_config.items()) + playbook_config = {k: str(v) for k, v in playbook_config.items()} # Now compare old and new config to detect if changes are needed if proxmox_config == playbook_config: @@ -622,7 +629,7 @@ class ProxmoxDiskAnsible(ProxmoxAnsible): params['format'] = self.module.params['format'] params['delete'] = 1 if self.module.params.get('delete_moved', False) else 0 # Remove not defined args - params = dict((k, v) for k, v in params.items() if v is not None) + params = {k: v for k, v in params.items() if v is not None} if params.get('storage', False): disk_config = disk_conf_str_to_dict(vm_config[disk]) diff --git a/plugins/modules/proxmox_domain_info.py b/plugins/modules/proxmox_domain_info.py index 7435695a91..f3ff212bff 100644 --- a/plugins/modules/proxmox_domain_info.py +++ b/plugins/modules/proxmox_domain_info.py @@ -16,6 +16,9 @@ short_description: Retrieve information about one or more Proxmox VE domains version_added: 1.3.0 description: - Retrieve information about one or more Proxmox VE domains. +attributes: + action_group: + version_added: 9.0.0 options: domain: description: @@ -24,6 +27,7 @@ options: type: str author: Tristan Le Guern (@tleguern) extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes - community.general.attributes.info_module diff --git a/plugins/modules/proxmox_group_info.py b/plugins/modules/proxmox_group_info.py index 531a9dae7a..eda1fe04d8 100644 --- a/plugins/modules/proxmox_group_info.py +++ b/plugins/modules/proxmox_group_info.py @@ -16,6 +16,9 @@ short_description: Retrieve information about one or more Proxmox VE groups version_added: 1.3.0 description: - Retrieve information about one or more Proxmox VE groups +attributes: + action_group: + version_added: 9.0.0 options: group: description: @@ -24,6 +27,7 @@ options: type: str author: Tristan Le Guern (@tleguern) extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes - community.general.attributes.info_module diff --git a/plugins/modules/proxmox_kvm.py b/plugins/modules/proxmox_kvm.py index 8779dcdc1f..771ddd902f 100644 --- a/plugins/modules/proxmox_kvm.py +++ b/plugins/modules/proxmox_kvm.py @@ -21,6 +21,8 @@ attributes: support: none diff_mode: support: none + action_group: + version_added: 9.0.0 options: archive: description: @@ -172,6 +174,7 @@ options: - Allow to force stop VM. - Can be used with states V(stopped), V(restarted), and V(absent). - This option has no default unless O(proxmox_default_behavior) is set to V(compatibility); then the default is V(false). + - Requires parameter O(archive). type: bool format: description: @@ -517,6 +520,16 @@ options: default: '2.0' type: dict version_added: 7.1.0 + usb: + description: + - A hash/dictionary of USB devices for the VM. O(usb='{"key":"value", "key":"value"}'). + - Keys allowed are - C(usb[n]) where 0 ≤ n ≤ N. + - Values allowed are - C(host="value|spice",mapping="value",usb3="1|0"). + - host is either C(spice) or the USB id/port. + - Option C(mapping) is the mapped USB device name. + - Option C(usb3) enables USB 3 support. + type: dict + version_added: 9.0.0 update: description: - If V(true), the VM will be updated with new value. @@ -579,6 +592,7 @@ options: seealso: - module: community.general.proxmox_vm_info extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.proxmox.selection - community.general.attributes @@ -956,7 +970,7 @@ class ProxmoxKvmAnsible(ProxmoxAnsible): self.module.fail_json(msg='Getting information for VM with vmid = %s failed with exception: %s' % (vmid, e)) # Sanitize kwargs. Remove not defined args and ensure True and False converted to int. - kwargs = dict((k, v) for k, v in kwargs.items() if v is not None) + kwargs = {k: v for k, v in kwargs.items() if v is not None} # Convert all dict in kwargs to elements. # For hostpci[n], ide[n], net[n], numa[n], parallel[n], sata[n], scsi[n], serial[n], virtio[n] @@ -982,7 +996,7 @@ class ProxmoxKvmAnsible(ProxmoxAnsible): proxmox_node = self.proxmox_api.nodes(node) # Sanitize kwargs. Remove not defined args and ensure True and False converted to int. - kwargs = dict((k, v) for k, v in kwargs.items() if v is not None) + kwargs = {k: v for k, v in kwargs.items() if v is not None} return proxmox_node.qemu(vmid).config.set(**kwargs) is None @@ -1017,8 +1031,8 @@ class ProxmoxKvmAnsible(ProxmoxAnsible): proxmox_node = self.proxmox_api.nodes(node) # Sanitize kwargs. Remove not defined args and ensure True and False converted to int. - kwargs = dict((k, v) for k, v in kwargs.items() if v is not None) - kwargs.update(dict([k, int(v)] for k, v in kwargs.items() if isinstance(v, bool))) + kwargs = {k: v for k, v in kwargs.items() if v is not None} + kwargs.update({k: int(v) for k, v in kwargs.items() if isinstance(v, bool)}) version = self.version() pve_major_version = 3 if version < LooseVersion('4.0') else version.version[0] @@ -1091,7 +1105,7 @@ class ProxmoxKvmAnsible(ProxmoxAnsible): ) # Convert all dict in kwargs to elements. - # For hostpci[n], ide[n], net[n], numa[n], parallel[n], sata[n], scsi[n], serial[n], virtio[n], ipconfig[n] + # For hostpci[n], ide[n], net[n], numa[n], parallel[n], sata[n], scsi[n], serial[n], virtio[n], ipconfig[n], usb[n] for k in list(kwargs.keys()): if isinstance(kwargs[k], dict): kwargs.update(kwargs[k]) @@ -1149,7 +1163,7 @@ class ProxmoxKvmAnsible(ProxmoxAnsible): for param in valid_clone_params: if self.module.params[param] is not None: clone_params[param] = self.module.params[param] - clone_params.update(dict([k, int(v)] for k, v in clone_params.items() if isinstance(v, bool))) + clone_params.update({k: int(v) for k, v in clone_params.items() if isinstance(v, bool)}) taskid = proxmox_node.qemu(vmid).clone.post(newid=newid, name=name, **clone_params) else: taskid = proxmox_node.qemu.create(vmid=vmid, name=name, memory=memory, cpu=cpu, cores=cores, sockets=sockets, **kwargs) @@ -1308,6 +1322,7 @@ def main(): storage=dict(type='str', required=True), version=dict(type='str', choices=['2.0', '1.2'], default='2.0') )), + usb=dict(type='dict'), update=dict(type='bool', default=False), update_unsafe=dict(type='bool', default=False), vcpus=dict(type='int'), @@ -1513,6 +1528,7 @@ def main(): tdf=module.params['tdf'], template=module.params['template'], tpmstate0=module.params['tpmstate0'], + usb=module.params['usb'], vcpus=module.params['vcpus'], vga=module.params['vga'], virtio=module.params['virtio'], diff --git a/plugins/modules/proxmox_nic.py b/plugins/modules/proxmox_nic.py index 9afe494472..6e94ed0bb6 100644 --- a/plugins/modules/proxmox_nic.py +++ b/plugins/modules/proxmox_nic.py @@ -21,6 +21,8 @@ attributes: support: full diff_mode: support: none + action_group: + version_added: 9.0.0 options: bridge: description: @@ -94,6 +96,7 @@ options: - Specifies the instance ID. type: int extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes ''' diff --git a/plugins/modules/proxmox_node_info.py b/plugins/modules/proxmox_node_info.py index 82ef7aa388..51d8745c05 100644 --- a/plugins/modules/proxmox_node_info.py +++ b/plugins/modules/proxmox_node_info.py @@ -17,7 +17,11 @@ version_added: 8.2.0 description: - Retrieve information about one or more Proxmox VE nodes. author: John Berninger (@jwbernin) +attributes: + action_group: + version_added: 9.0.0 extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes - community.general.attributes.info_module diff --git a/plugins/modules/proxmox_pool.py b/plugins/modules/proxmox_pool.py index 7046320700..5089ec3bef 100644 --- a/plugins/modules/proxmox_pool.py +++ b/plugins/modules/proxmox_pool.py @@ -21,6 +21,8 @@ attributes: support: full diff_mode: support: none + action_group: + version_added: 9.0.0 options: poolid: description: @@ -42,8 +44,9 @@ options: type: str extends_documentation_fragment: - - community.general.proxmox.documentation - - community.general.attributes + - community.general.proxmox.actiongroup_proxmox + - community.general.proxmox.documentation + - community.general.attributes """ EXAMPLES = """ diff --git a/plugins/modules/proxmox_pool_member.py b/plugins/modules/proxmox_pool_member.py index 7d6b249493..b26082f975 100644 --- a/plugins/modules/proxmox_pool_member.py +++ b/plugins/modules/proxmox_pool_member.py @@ -20,6 +20,8 @@ attributes: support: full diff_mode: support: full + action_group: + version_added: 9.0.0 options: poolid: description: @@ -48,8 +50,9 @@ options: type: str extends_documentation_fragment: - - community.general.proxmox.documentation - - community.general.attributes + - community.general.proxmox.actiongroup_proxmox + - community.general.proxmox.documentation + - community.general.attributes """ EXAMPLES = """ diff --git a/plugins/modules/proxmox_snap.py b/plugins/modules/proxmox_snap.py index 4991423c2a..4f7b345b80 100644 --- a/plugins/modules/proxmox_snap.py +++ b/plugins/modules/proxmox_snap.py @@ -21,6 +21,8 @@ attributes: support: full diff_mode: support: none + action_group: + version_added: 9.0.0 options: hostname: description: @@ -89,8 +91,9 @@ notes: requirements: [ "proxmoxer", "requests" ] author: Jeffrey van Pelt (@Thulium-Drake) extends_documentation_fragment: - - community.general.proxmox.documentation - - community.general.attributes + - community.general.proxmox.actiongroup_proxmox + - community.general.proxmox.documentation + - community.general.attributes ''' EXAMPLES = r''' diff --git a/plugins/modules/proxmox_storage_contents_info.py b/plugins/modules/proxmox_storage_contents_info.py index 498490fe41..b777870e54 100644 --- a/plugins/modules/proxmox_storage_contents_info.py +++ b/plugins/modules/proxmox_storage_contents_info.py @@ -17,6 +17,9 @@ short_description: List content from a Proxmox VE storage version_added: 8.2.0 description: - Retrieves information about stored objects on a specific storage attached to a node. +attributes: + action_group: + version_added: 9.0.0 options: storage: description: @@ -41,6 +44,7 @@ options: type: int author: Julian Vanden Broeck (@l00ptr) extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes - community.general.attributes.info_module diff --git a/plugins/modules/proxmox_storage_info.py b/plugins/modules/proxmox_storage_info.py index 3c29e59cf2..fd5a6ee0d8 100644 --- a/plugins/modules/proxmox_storage_info.py +++ b/plugins/modules/proxmox_storage_info.py @@ -16,6 +16,9 @@ short_description: Retrieve information about one or more Proxmox VE storages version_added: 2.2.0 description: - Retrieve information about one or more Proxmox VE storages. +attributes: + action_group: + version_added: 9.0.0 options: storage: description: @@ -28,6 +31,7 @@ options: type: str author: Tristan Le Guern (@tleguern) extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes - community.general.attributes.info_module diff --git a/plugins/modules/proxmox_tasks_info.py b/plugins/modules/proxmox_tasks_info.py index d31a04980b..65a07566a8 100644 --- a/plugins/modules/proxmox_tasks_info.py +++ b/plugins/modules/proxmox_tasks_info.py @@ -17,6 +17,9 @@ version_added: 3.8.0 description: - Retrieve information about one or more Proxmox VE tasks. author: 'Andreas Botzner (@paginabianca) ' +attributes: + action_group: + version_added: 9.0.0 options: node: description: @@ -29,9 +32,10 @@ options: aliases: ['upid', 'name'] type: str extends_documentation_fragment: - - community.general.proxmox.documentation - - community.general.attributes - - community.general.attributes.info_module + - community.general.proxmox.actiongroup_proxmox + - community.general.proxmox.documentation + - community.general.attributes + - community.general.attributes.info_module ''' diff --git a/plugins/modules/proxmox_template.py b/plugins/modules/proxmox_template.py index 615bfc1823..134286164c 100644 --- a/plugins/modules/proxmox_template.py +++ b/plugins/modules/proxmox_template.py @@ -20,6 +20,8 @@ attributes: support: none diff_mode: support: none + action_group: + version_added: 9.0.0 options: node: description: @@ -69,6 +71,7 @@ notes: - C(proxmoxer) >= 1.2.0 requires C(requests_toolbelt) to upload files larger than 256 MB. author: Sergei Antipov (@UnderGreen) extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes ''' @@ -141,12 +144,12 @@ except ImportError: class ProxmoxTemplateAnsible(ProxmoxAnsible): - def get_template(self, node, storage, content_type, template): + def has_template(self, node, storage, content_type, template): + volid = '%s:%s/%s' % (storage, content_type, template) try: - return [True for tmpl in self.proxmox_api.nodes(node).storage(storage).content.get() - if tmpl['volid'] == '%s:%s/%s' % (storage, content_type, template)] + return any(tmpl['volid'] == volid for tmpl in self.proxmox_api.nodes(node).storage(storage).content.get()) except Exception as e: - self.module.fail_json(msg="Failed to retrieve template '%s:%s/%s': %s" % (storage, content_type, template, e)) + self.module.fail_json(msg="Failed to retrieve template '%s': %s" % (volid, e)) def task_status(self, node, taskid, timeout): """ @@ -187,7 +190,7 @@ class ProxmoxTemplateAnsible(ProxmoxAnsible): volid = '%s:%s/%s' % (storage, content_type, template) self.proxmox_api.nodes(node).storage(storage).content.delete(volid) while timeout: - if not self.get_template(node, storage, content_type, template): + if not self.has_template(node, storage, content_type, template): return True timeout = timeout - 1 if timeout == 0: @@ -236,14 +239,14 @@ def main(): if not template: module.fail_json(msg='template param for downloading appliance template is mandatory') - if proxmox.get_template(node, storage, content_type, template) and not module.params['force']: + if proxmox.has_template(node, storage, content_type, template) and not module.params['force']: module.exit_json(changed=False, msg='template with volid=%s:%s/%s already exists' % (storage, content_type, template)) if proxmox.download_template(node, storage, template, timeout): module.exit_json(changed=True, msg='template with volid=%s:%s/%s downloaded' % (storage, content_type, template)) template = os.path.basename(src) - if proxmox.get_template(node, storage, content_type, template) and not module.params['force']: + if proxmox.has_template(node, storage, content_type, template) and not module.params['force']: module.exit_json(changed=False, msg='template with volid=%s:%s/%s is already exists' % (storage, content_type, template)) elif not src: module.fail_json(msg='src param to uploading template file is mandatory') @@ -258,7 +261,7 @@ def main(): content_type = module.params['content_type'] template = module.params['template'] - if not proxmox.get_template(node, storage, content_type, template): + if not proxmox.has_template(node, storage, content_type, template): module.exit_json(changed=False, msg='template with volid=%s:%s/%s is already deleted' % (storage, content_type, template)) if proxmox.delete_template(node, storage, content_type, template, timeout): diff --git a/plugins/modules/proxmox_user_info.py b/plugins/modules/proxmox_user_info.py index 20154528a6..8680dec7ca 100644 --- a/plugins/modules/proxmox_user_info.py +++ b/plugins/modules/proxmox_user_info.py @@ -16,6 +16,9 @@ short_description: Retrieve information about one or more Proxmox VE users version_added: 1.3.0 description: - Retrieve information about one or more Proxmox VE users +attributes: + action_group: + version_added: 9.0.0 options: domain: description: @@ -33,6 +36,7 @@ options: type: str author: Tristan Le Guern (@tleguern) extends_documentation_fragment: + - community.general.proxmox.actiongroup_proxmox - community.general.proxmox.documentation - community.general.attributes - community.general.attributes.info_module diff --git a/plugins/modules/proxmox_vm_info.py b/plugins/modules/proxmox_vm_info.py index 30342b684e..e10b9dff6f 100644 --- a/plugins/modules/proxmox_vm_info.py +++ b/plugins/modules/proxmox_vm_info.py @@ -17,6 +17,9 @@ version_added: 7.2.0 description: - Retrieve information about one or more Proxmox VE virtual machines. author: 'Sergei Antipov (@UnderGreen) ' +attributes: + action_group: + version_added: 9.0.0 options: node: description: @@ -54,10 +57,18 @@ options: - pending default: none version_added: 8.1.0 + network: + description: + - Whether to retrieve the current network status. + - Requires enabled/running qemu-guest-agent on qemu VMs. + type: bool + default: false + version_added: 9.1.0 extends_documentation_fragment: - - community.general.proxmox.documentation - - community.general.attributes - - community.general.attributes.info_module + - community.general.proxmox.actiongroup_proxmox + - community.general.proxmox.documentation + - community.general.attributes + - community.general.attributes.info_module """ EXAMPLES = """ @@ -168,7 +179,7 @@ class ProxmoxVmInfoAnsible(ProxmoxAnsible): msg="Failed to retrieve VMs information from cluster resources: %s" % e ) - def get_vms_from_nodes(self, cluster_machines, type, vmid=None, name=None, node=None, config=None): + def get_vms_from_nodes(self, cluster_machines, type, vmid=None, name=None, node=None, config=None, network=False): # Leave in dict only machines that user wants to know about filtered_vms = { vm: info for vm, info in cluster_machines.items() if not ( @@ -197,17 +208,23 @@ class ProxmoxVmInfoAnsible(ProxmoxAnsible): config_type = 0 if config == "pending" else 1 # GET /nodes/{node}/qemu/{vmid}/config current=[0/1] desired_vm["config"] = call_vm_getter(this_vm_id).config().get(current=config_type) + if network: + if type == "qemu": + desired_vm["network"] = call_vm_getter(this_vm_id).agent("network-get-interfaces").get()['result'] + elif type == "lxc": + desired_vm["network"] = call_vm_getter(this_vm_id).interfaces.get() + return filtered_vms - def get_qemu_vms(self, cluster_machines, vmid=None, name=None, node=None, config=None): + def get_qemu_vms(self, cluster_machines, vmid=None, name=None, node=None, config=None, network=False): try: - return self.get_vms_from_nodes(cluster_machines, "qemu", vmid, name, node, config) + return self.get_vms_from_nodes(cluster_machines, "qemu", vmid, name, node, config, network) except Exception as e: self.module.fail_json(msg="Failed to retrieve QEMU VMs information: %s" % e) - def get_lxc_vms(self, cluster_machines, vmid=None, name=None, node=None, config=None): + def get_lxc_vms(self, cluster_machines, vmid=None, name=None, node=None, config=None, network=False): try: - return self.get_vms_from_nodes(cluster_machines, "lxc", vmid, name, node, config) + return self.get_vms_from_nodes(cluster_machines, "lxc", vmid, name, node, config, network) except Exception as e: self.module.fail_json(msg="Failed to retrieve LXC VMs information: %s" % e) @@ -225,6 +242,7 @@ def main(): type="str", choices=["none", "current", "pending"], default="none", required=False ), + network=dict(type="bool", default=False, required=False), ) module_args.update(vm_info_args) @@ -241,6 +259,7 @@ def main(): vmid = module.params["vmid"] name = module.params["name"] config = module.params["config"] + network = module.params["network"] result = dict(changed=False) @@ -252,12 +271,12 @@ def main(): vms = {} if type == "lxc": - vms = proxmox.get_lxc_vms(cluster_machines, vmid, name, node, config) + vms = proxmox.get_lxc_vms(cluster_machines, vmid, name, node, config, network) elif type == "qemu": - vms = proxmox.get_qemu_vms(cluster_machines, vmid, name, node, config) + vms = proxmox.get_qemu_vms(cluster_machines, vmid, name, node, config, network) else: - vms = proxmox.get_qemu_vms(cluster_machines, vmid, name, node, config) - vms.update(proxmox.get_lxc_vms(cluster_machines, vmid, name, node, config)) + vms = proxmox.get_qemu_vms(cluster_machines, vmid, name, node, config, network) + vms.update(proxmox.get_lxc_vms(cluster_machines, vmid, name, node, config, network)) result["proxmox_vms"] = [info for vm, info in sorted(vms.items())] module.exit_json(**result) diff --git a/plugins/modules/puppet.py b/plugins/modules/puppet.py index 86eac062a8..46326c667f 100644 --- a/plugins/modules/puppet.py +++ b/plugins/modules/puppet.py @@ -101,6 +101,12 @@ options: - Whether to print a transaction summary. type: bool default: false + waitforlock: + description: + - The maximum amount of time C(puppet) should wait for an already running C(puppet) agent to finish before starting. + - If a number without unit is provided, it is assumed to be a number of seconds. Allowed units are V(m) for minutes and V(h) for hours. + type: str + version_added: 9.0.0 verbose: description: - Print extra information. @@ -116,6 +122,17 @@ options: - Whether to print file changes details type: bool default: false + environment_lang: + description: + - The lang environment to use when running the puppet agent. + - The default value, V(C), is supported on every system, but can lead to encoding errors if UTF-8 is used in the output + - Use V(C.UTF-8) or V(en_US.UTF-8) or similar UTF-8 supporting locales in case of problems. You need to make sure + the selected locale is supported on the system the puppet agent runs on. + - Starting with community.general 9.1.0, you can use the value V(auto) and the module will + try and determine the best parseable locale to use. + type: str + default: C + version_added: 8.6.0 requirements: - puppet author: @@ -150,6 +167,14 @@ EXAMPLES = r''' skip_tags: - service +- name: Wait 30 seconds for any current puppet runs to finish + community.general.puppet: + waitforlock: 30 + +- name: Wait 5 minutes for any current puppet runs to finish + community.general.puppet: + waitforlock: 5m + - name: Run puppet agent in noop mode community.general.puppet: noop: true @@ -205,9 +230,11 @@ def main(): skip_tags=dict(type='list', elements='str'), execute=dict(type='str'), summarize=dict(type='bool', default=False), + waitforlock=dict(type='str'), debug=dict(type='bool', default=False), verbose=dict(type='bool', default=False), use_srv_records=dict(type='bool'), + environment_lang=dict(type='str', default='C'), ), supports_check_mode=True, mutually_exclusive=[ @@ -237,11 +264,11 @@ def main(): runner = puppet_utils.puppet_runner(module) if not p['manifest'] and not p['execute']: - args_order = "_agent_fixed puppetmaster show_diff confdir environment tags skip_tags certname noop use_srv_records" + args_order = "_agent_fixed puppetmaster show_diff confdir environment tags skip_tags certname noop use_srv_records waitforlock" with runner(args_order) as ctx: rc, stdout, stderr = ctx.run() else: - args_order = "_apply_fixed logdest modulepath environment certname tags skip_tags noop _execute summarize debug verbose" + args_order = "_apply_fixed logdest modulepath environment certname tags skip_tags noop _execute summarize debug verbose waitforlock" with runner(args_order) as ctx: rc, stdout, stderr = ctx.run(_execute=[p['execute'], p['manifest']]) diff --git a/plugins/modules/rax.py b/plugins/modules/rax.py deleted file mode 100644 index 76e4299447..0000000000 --- a/plugins/modules/rax.py +++ /dev/null @@ -1,903 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax -short_description: Create / delete an instance in Rackspace Public Cloud -description: - - creates / deletes a Rackspace Public Cloud instance and optionally - waits for it to be 'running'. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - auto_increment: - description: - - Whether or not to increment a single number with the name of the - created servers. Only applicable when used with the O(group) attribute - or meta key. - type: bool - default: true - boot_from_volume: - description: - - Whether or not to boot the instance from a Cloud Block Storage volume. - If V(true) and O(image) is specified a new volume will be created at - boot time. O(boot_volume_size) is required with O(image) to create a - new volume at boot time. - type: bool - default: false - boot_volume: - type: str - description: - - Cloud Block Storage ID or Name to use as the boot volume of the - instance - boot_volume_size: - type: int - description: - - Size of the volume to create in Gigabytes. This is only required with - O(image) and O(boot_from_volume). - default: 100 - boot_volume_terminate: - description: - - Whether the O(boot_volume) or newly created volume from O(image) will - be terminated when the server is terminated - type: bool - default: false - config_drive: - description: - - Attach read-only configuration drive to server as label config-2 - type: bool - default: false - count: - type: int - description: - - number of instances to launch - default: 1 - count_offset: - type: int - description: - - number count to start at - default: 1 - disk_config: - type: str - description: - - Disk partitioning strategy - - If not specified it will assume the value V(auto). - choices: - - auto - - manual - exact_count: - description: - - Explicitly ensure an exact count of instances, used with - state=active/present. If specified as V(true) and O(count) is less than - the servers matched, servers will be deleted to match the count. If - the number of matched servers is fewer than specified in O(count) - additional servers will be added. - type: bool - default: false - extra_client_args: - type: dict - default: {} - description: - - A hash of key/value pairs to be used when creating the cloudservers - client. This is considered an advanced option, use it wisely and - with caution. - extra_create_args: - type: dict - default: {} - description: - - A hash of key/value pairs to be used when creating a new server. - This is considered an advanced option, use it wisely and with caution. - files: - type: dict - default: {} - description: - - Files to insert into the instance. remotefilename:localcontent - flavor: - type: str - description: - - flavor to use for the instance - group: - type: str - description: - - host group to assign to server, is also used for idempotent operations - to ensure a specific number of instances - image: - type: str - description: - - image to use for the instance. Can be an C(id), C(human_id) or C(name). - With O(boot_from_volume), a Cloud Block Storage volume will be created - with this image - instance_ids: - type: list - elements: str - description: - - list of instance ids, currently only used when state='absent' to - remove instances - key_name: - type: str - description: - - key pair to use on the instance - aliases: - - keypair - meta: - type: dict - default: {} - description: - - A hash of metadata to associate with the instance - name: - type: str - description: - - Name to give the instance - networks: - type: list - elements: str - description: - - The network to attach to the instances. If specified, you must include - ALL networks including the public and private interfaces. Can be C(id) - or C(label). - default: - - public - - private - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - user_data: - type: str - description: - - Data to be uploaded to the servers config drive. This option implies - O(config_drive). Can be a file path or a string - wait: - description: - - wait for the instance to be in state 'running' before returning - type: bool - default: false - wait_timeout: - type: int - description: - - how long before wait gives up, in seconds - default: 300 -author: - - "Jesse Keating (@omgjlk)" - - "Matt Martz (@sivel)" -notes: - - O(exact_count) can be "destructive" if the number of running servers in - the O(group) is larger than that specified in O(count). In such a case, the - O(state) is effectively set to V(absent) and the extra servers are deleted. - In the case of deletion, the returned data structure will have RV(ignore:action) - set to V(delete), and the oldest servers in the group will be deleted. -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a Cloud Server - gather_facts: false - tasks: - - name: Server build request - local_action: - module: rax - credentials: ~/.raxpub - name: rax-test1 - flavor: 5 - image: b11d9567-e412-4255-96b9-bd63ab23bcfe - key_name: my_rackspace_key - files: - /root/test.txt: /home/localuser/test.txt - wait: true - state: present - networks: - - private - - public - register: rax - -- name: Build an exact count of cloud servers with incremented names - hosts: local - gather_facts: false - tasks: - - name: Server build requests - local_action: - module: rax - credentials: ~/.raxpub - name: test%03d.example.org - flavor: performance1-1 - image: ubuntu-1204-lts-precise-pangolin - state: present - count: 10 - count_offset: 10 - exact_count: true - group: test - wait: true - register: rax -''' - -import json -import os -import re -import time - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (FINAL_STATUSES, rax_argument_spec, rax_find_bootable_volume, - rax_find_image, rax_find_network, rax_find_volume, - rax_required_together, rax_to_dict, setup_rax_module) -from ansible.module_utils.six.moves import xrange -from ansible.module_utils.six import string_types - - -def rax_find_server_image(module, server, image, boot_volume): - if not image and boot_volume: - vol = rax_find_bootable_volume(module, pyrax, server, - exit=False) - if not vol: - return None - volume_image_metadata = vol.volume_image_metadata - vol_image_id = volume_image_metadata.get('image_id') - if vol_image_id: - server_image = rax_find_image(module, pyrax, - vol_image_id, exit=False) - if server_image: - server.image = dict(id=server_image) - - # Match image IDs taking care of boot from volume - if image and not server.image: - vol = rax_find_bootable_volume(module, pyrax, server) - volume_image_metadata = vol.volume_image_metadata - vol_image_id = volume_image_metadata.get('image_id') - if not vol_image_id: - return None - server_image = rax_find_image(module, pyrax, - vol_image_id, exit=False) - if image != server_image: - return None - - server.image = dict(id=server_image) - elif image and server.image['id'] != image: - return None - - return server.image - - -def create(module, names=None, flavor=None, image=None, meta=None, key_name=None, - files=None, wait=True, wait_timeout=300, disk_config=None, - group=None, nics=None, extra_create_args=None, user_data=None, - config_drive=False, existing=None, block_device_mapping_v2=None): - names = [] if names is None else names - meta = {} if meta is None else meta - files = {} if files is None else files - nics = [] if nics is None else nics - extra_create_args = {} if extra_create_args is None else extra_create_args - existing = [] if existing is None else existing - block_device_mapping_v2 = [] if block_device_mapping_v2 is None else block_device_mapping_v2 - - cs = pyrax.cloudservers - changed = False - - if user_data: - config_drive = True - - if user_data and os.path.isfile(os.path.expanduser(user_data)): - try: - user_data = os.path.expanduser(user_data) - f = open(user_data) - user_data = f.read() - f.close() - except Exception as e: - module.fail_json(msg='Failed to load %s' % user_data) - - # Handle the file contents - for rpath in files.keys(): - lpath = os.path.expanduser(files[rpath]) - try: - fileobj = open(lpath, 'r') - files[rpath] = fileobj.read() - fileobj.close() - except Exception as e: - module.fail_json(msg='Failed to load %s' % lpath) - try: - servers = [] - bdmv2 = block_device_mapping_v2 - for name in names: - servers.append(cs.servers.create(name=name, image=image, - flavor=flavor, meta=meta, - key_name=key_name, - files=files, nics=nics, - disk_config=disk_config, - config_drive=config_drive, - userdata=user_data, - block_device_mapping_v2=bdmv2, - **extra_create_args)) - except Exception as e: - if e.message: - msg = str(e.message) - else: - msg = repr(e) - module.fail_json(msg=msg) - else: - changed = True - - if wait: - end_time = time.time() + wait_timeout - infinite = wait_timeout == 0 - while infinite or time.time() < end_time: - for server in servers: - try: - server.get() - except Exception: - server.status = 'ERROR' - - if not filter(lambda s: s.status not in FINAL_STATUSES, - servers): - break - time.sleep(5) - - success = [] - error = [] - timeout = [] - for server in servers: - try: - server.get() - except Exception: - server.status = 'ERROR' - instance = rax_to_dict(server, 'server') - if server.status == 'ACTIVE' or not wait: - success.append(instance) - elif server.status == 'ERROR': - error.append(instance) - elif wait: - timeout.append(instance) - - untouched = [rax_to_dict(s, 'server') for s in existing] - instances = success + untouched - - results = { - 'changed': changed, - 'action': 'create', - 'instances': instances, - 'success': success, - 'error': error, - 'timeout': timeout, - 'instance_ids': { - 'instances': [i['id'] for i in instances], - 'success': [i['id'] for i in success], - 'error': [i['id'] for i in error], - 'timeout': [i['id'] for i in timeout] - } - } - - if timeout: - results['msg'] = 'Timeout waiting for all servers to build' - elif error: - results['msg'] = 'Failed to build all servers' - - if 'msg' in results: - module.fail_json(**results) - else: - module.exit_json(**results) - - -def delete(module, instance_ids=None, wait=True, wait_timeout=300, kept=None): - instance_ids = [] if instance_ids is None else instance_ids - kept = [] if kept is None else kept - - cs = pyrax.cloudservers - - changed = False - instances = {} - servers = [] - - for instance_id in instance_ids: - servers.append(cs.servers.get(instance_id)) - - for server in servers: - try: - server.delete() - except Exception as e: - module.fail_json(msg=e.message) - else: - changed = True - - instance = rax_to_dict(server, 'server') - instances[instance['id']] = instance - - # If requested, wait for server deletion - if wait: - end_time = time.time() + wait_timeout - infinite = wait_timeout == 0 - while infinite or time.time() < end_time: - for server in servers: - instance_id = server.id - try: - server.get() - except Exception: - instances[instance_id]['status'] = 'DELETED' - instances[instance_id]['rax_status'] = 'DELETED' - - if not filter(lambda s: s['status'] not in ('', 'DELETED', - 'ERROR'), - instances.values()): - break - - time.sleep(5) - - timeout = filter(lambda s: s['status'] not in ('', 'DELETED', 'ERROR'), - instances.values()) - error = filter(lambda s: s['status'] in ('ERROR'), - instances.values()) - success = filter(lambda s: s['status'] in ('', 'DELETED'), - instances.values()) - - instances = [rax_to_dict(s, 'server') for s in kept] - - results = { - 'changed': changed, - 'action': 'delete', - 'instances': instances, - 'success': success, - 'error': error, - 'timeout': timeout, - 'instance_ids': { - 'instances': [i['id'] for i in instances], - 'success': [i['id'] for i in success], - 'error': [i['id'] for i in error], - 'timeout': [i['id'] for i in timeout] - } - } - - if timeout: - results['msg'] = 'Timeout waiting for all servers to delete' - elif error: - results['msg'] = 'Failed to delete all servers' - - if 'msg' in results: - module.fail_json(**results) - else: - module.exit_json(**results) - - -def cloudservers(module, state=None, name=None, flavor=None, image=None, - meta=None, key_name=None, files=None, wait=True, wait_timeout=300, - disk_config=None, count=1, group=None, instance_ids=None, - exact_count=False, networks=None, count_offset=0, - auto_increment=False, extra_create_args=None, user_data=None, - config_drive=False, boot_from_volume=False, - boot_volume=None, boot_volume_size=None, - boot_volume_terminate=False): - meta = {} if meta is None else meta - files = {} if files is None else files - instance_ids = [] if instance_ids is None else instance_ids - networks = [] if networks is None else networks - extra_create_args = {} if extra_create_args is None else extra_create_args - - cs = pyrax.cloudservers - cnw = pyrax.cloud_networks - if not cnw: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if state == 'present' or (state == 'absent' and instance_ids is None): - if not boot_from_volume and not boot_volume and not image: - module.fail_json(msg='image is required for the "rax" module') - - for arg, value in dict(name=name, flavor=flavor).items(): - if not value: - module.fail_json(msg='%s is required for the "rax" module' % - arg) - - if boot_from_volume and not image and not boot_volume: - module.fail_json(msg='image or boot_volume are required for the ' - '"rax" with boot_from_volume') - - if boot_from_volume and image and not boot_volume_size: - module.fail_json(msg='boot_volume_size is required for the "rax" ' - 'module with boot_from_volume and image') - - if boot_from_volume and image and boot_volume: - image = None - - servers = [] - - # Add the group meta key - if group and 'group' not in meta: - meta['group'] = group - elif 'group' in meta and group is None: - group = meta['group'] - - # Normalize and ensure all metadata values are strings - for k, v in meta.items(): - if isinstance(v, list): - meta[k] = ','.join(['%s' % i for i in v]) - elif isinstance(v, dict): - meta[k] = json.dumps(v) - elif not isinstance(v, string_types): - meta[k] = '%s' % v - - # When using state=absent with group, the absent block won't match the - # names properly. Use the exact_count functionality to decrease the count - # to the desired level - was_absent = False - if group is not None and state == 'absent': - exact_count = True - state = 'present' - was_absent = True - - if image: - image = rax_find_image(module, pyrax, image) - - nics = [] - if networks: - for network in networks: - nics.extend(rax_find_network(module, pyrax, network)) - - # act on the state - if state == 'present': - # Idempotent ensurance of a specific count of servers - if exact_count is not False: - # See if we can find servers that match our options - if group is None: - module.fail_json(msg='"group" must be provided when using ' - '"exact_count"') - - if auto_increment: - numbers = set() - - # See if the name is a printf like string, if not append - # %d to the end - try: - name % 0 - except TypeError as e: - if e.message.startswith('not all'): - name = '%s%%d' % name - else: - module.fail_json(msg=e.message) - - # regex pattern to match printf formatting - pattern = re.sub(r'%\d*[sd]', r'(\d+)', name) - for server in cs.servers.list(): - # Ignore DELETED servers - if server.status == 'DELETED': - continue - if server.metadata.get('group') == group: - servers.append(server) - match = re.search(pattern, server.name) - if match: - number = int(match.group(1)) - numbers.add(number) - - number_range = xrange(count_offset, count_offset + count) - available_numbers = list(set(number_range) - .difference(numbers)) - else: # Not auto incrementing - for server in cs.servers.list(): - # Ignore DELETED servers - if server.status == 'DELETED': - continue - if server.metadata.get('group') == group: - servers.append(server) - # available_numbers not needed here, we inspect auto_increment - # again later - - # If state was absent but the count was changed, - # assume we only wanted to remove that number of instances - if was_absent: - diff = len(servers) - count - if diff < 0: - count = 0 - else: - count = diff - - if len(servers) > count: - # We have more servers than we need, set state='absent' - # and delete the extras, this should delete the oldest - state = 'absent' - kept = servers[:count] - del servers[:count] - instance_ids = [] - for server in servers: - instance_ids.append(server.id) - delete(module, instance_ids=instance_ids, wait=wait, - wait_timeout=wait_timeout, kept=kept) - elif len(servers) < count: - # we have fewer servers than we need - if auto_increment: - # auto incrementing server numbers - names = [] - name_slice = count - len(servers) - numbers_to_use = available_numbers[:name_slice] - for number in numbers_to_use: - names.append(name % number) - else: - # We are not auto incrementing server numbers, - # create a list of 'name' that matches how many we need - names = [name] * (count - len(servers)) - else: - # we have the right number of servers, just return info - # about all of the matched servers - instances = [] - instance_ids = [] - for server in servers: - instances.append(rax_to_dict(server, 'server')) - instance_ids.append(server.id) - module.exit_json(changed=False, action=None, - instances=instances, - success=[], error=[], timeout=[], - instance_ids={'instances': instance_ids, - 'success': [], 'error': [], - 'timeout': []}) - else: # not called with exact_count=True - if group is not None: - if auto_increment: - # we are auto incrementing server numbers, but not with - # exact_count - numbers = set() - - # See if the name is a printf like string, if not append - # %d to the end - try: - name % 0 - except TypeError as e: - if e.message.startswith('not all'): - name = '%s%%d' % name - else: - module.fail_json(msg=e.message) - - # regex pattern to match printf formatting - pattern = re.sub(r'%\d*[sd]', r'(\d+)', name) - for server in cs.servers.list(): - # Ignore DELETED servers - if server.status == 'DELETED': - continue - if server.metadata.get('group') == group: - servers.append(server) - match = re.search(pattern, server.name) - if match: - number = int(match.group(1)) - numbers.add(number) - - number_range = xrange(count_offset, - count_offset + count + len(numbers)) - available_numbers = list(set(number_range) - .difference(numbers)) - names = [] - numbers_to_use = available_numbers[:count] - for number in numbers_to_use: - names.append(name % number) - else: - # Not auto incrementing - names = [name] * count - else: - # No group was specified, and not using exact_count - # Perform more simplistic matching - search_opts = { - 'name': '^%s$' % name, - 'flavor': flavor - } - servers = [] - for server in cs.servers.list(search_opts=search_opts): - # Ignore DELETED servers - if server.status == 'DELETED': - continue - - if not rax_find_server_image(module, server, image, - boot_volume): - continue - - # Ignore servers with non matching metadata - if server.metadata != meta: - continue - servers.append(server) - - if len(servers) >= count: - # We have more servers than were requested, don't do - # anything. Not running with exact_count=True, so we assume - # more is OK - instances = [] - for server in servers: - instances.append(rax_to_dict(server, 'server')) - - instance_ids = [i['id'] for i in instances] - module.exit_json(changed=False, action=None, - instances=instances, success=[], error=[], - timeout=[], - instance_ids={'instances': instance_ids, - 'success': [], 'error': [], - 'timeout': []}) - - # We need more servers to reach out target, create names for - # them, we aren't performing auto_increment here - names = [name] * (count - len(servers)) - - block_device_mapping_v2 = [] - if boot_from_volume: - mapping = { - 'boot_index': '0', - 'delete_on_termination': boot_volume_terminate, - 'destination_type': 'volume', - } - if image: - mapping.update({ - 'uuid': image, - 'source_type': 'image', - 'volume_size': boot_volume_size, - }) - image = None - elif boot_volume: - volume = rax_find_volume(module, pyrax, boot_volume) - mapping.update({ - 'uuid': pyrax.utils.get_id(volume), - 'source_type': 'volume', - }) - block_device_mapping_v2.append(mapping) - - create(module, names=names, flavor=flavor, image=image, - meta=meta, key_name=key_name, files=files, wait=wait, - wait_timeout=wait_timeout, disk_config=disk_config, group=group, - nics=nics, extra_create_args=extra_create_args, - user_data=user_data, config_drive=config_drive, - existing=servers, - block_device_mapping_v2=block_device_mapping_v2) - - elif state == 'absent': - if instance_ids is None: - # We weren't given an explicit list of server IDs to delete - # Let's match instead - search_opts = { - 'name': '^%s$' % name, - 'flavor': flavor - } - for server in cs.servers.list(search_opts=search_opts): - # Ignore DELETED servers - if server.status == 'DELETED': - continue - - if not rax_find_server_image(module, server, image, - boot_volume): - continue - - # Ignore servers with non matching metadata - if meta != server.metadata: - continue - - servers.append(server) - - # Build a list of server IDs to delete - instance_ids = [] - for server in servers: - if len(instance_ids) < count: - instance_ids.append(server.id) - else: - break - - if not instance_ids: - # No server IDs were matched for deletion, or no IDs were - # explicitly provided, just exit and don't do anything - module.exit_json(changed=False, action=None, instances=[], - success=[], error=[], timeout=[], - instance_ids={'instances': [], - 'success': [], 'error': [], - 'timeout': []}) - - delete(module, instance_ids=instance_ids, wait=wait, - wait_timeout=wait_timeout) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - auto_increment=dict(default=True, type='bool'), - boot_from_volume=dict(default=False, type='bool'), - boot_volume=dict(type='str'), - boot_volume_size=dict(type='int', default=100), - boot_volume_terminate=dict(type='bool', default=False), - config_drive=dict(default=False, type='bool'), - count=dict(default=1, type='int'), - count_offset=dict(default=1, type='int'), - disk_config=dict(choices=['auto', 'manual']), - exact_count=dict(default=False, type='bool'), - extra_client_args=dict(type='dict', default={}), - extra_create_args=dict(type='dict', default={}), - files=dict(type='dict', default={}), - flavor=dict(), - group=dict(), - image=dict(), - instance_ids=dict(type='list', elements='str'), - key_name=dict(aliases=['keypair']), - meta=dict(type='dict', default={}), - name=dict(), - networks=dict(type='list', elements='str', default=['public', 'private']), - state=dict(default='present', choices=['present', 'absent']), - user_data=dict(no_log=True), - wait=dict(default=False, type='bool'), - wait_timeout=dict(default=300, type='int'), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - auto_increment = module.params.get('auto_increment') - boot_from_volume = module.params.get('boot_from_volume') - boot_volume = module.params.get('boot_volume') - boot_volume_size = module.params.get('boot_volume_size') - boot_volume_terminate = module.params.get('boot_volume_terminate') - config_drive = module.params.get('config_drive') - count = module.params.get('count') - count_offset = module.params.get('count_offset') - disk_config = module.params.get('disk_config') - if disk_config: - disk_config = disk_config.upper() - exact_count = module.params.get('exact_count', False) - extra_client_args = module.params.get('extra_client_args') - extra_create_args = module.params.get('extra_create_args') - files = module.params.get('files') - flavor = module.params.get('flavor') - group = module.params.get('group') - image = module.params.get('image') - instance_ids = module.params.get('instance_ids') - key_name = module.params.get('key_name') - meta = module.params.get('meta') - name = module.params.get('name') - networks = module.params.get('networks') - state = module.params.get('state') - user_data = module.params.get('user_data') - wait = module.params.get('wait') - wait_timeout = int(module.params.get('wait_timeout')) - - setup_rax_module(module, pyrax) - - if extra_client_args: - pyrax.cloudservers = pyrax.connect_to_cloudservers( - region=pyrax.cloudservers.client.region_name, - **extra_client_args) - client = pyrax.cloudservers.client - if 'bypass_url' in extra_client_args: - client.management_url = extra_client_args['bypass_url'] - - if pyrax.cloudservers is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - cloudservers(module, state=state, name=name, flavor=flavor, - image=image, meta=meta, key_name=key_name, files=files, - wait=wait, wait_timeout=wait_timeout, disk_config=disk_config, - count=count, group=group, instance_ids=instance_ids, - exact_count=exact_count, networks=networks, - count_offset=count_offset, auto_increment=auto_increment, - extra_create_args=extra_create_args, user_data=user_data, - config_drive=config_drive, boot_from_volume=boot_from_volume, - boot_volume=boot_volume, boot_volume_size=boot_volume_size, - boot_volume_terminate=boot_volume_terminate) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_cbs.py b/plugins/modules/rax_cbs.py deleted file mode 100644 index 77e7cebad4..0000000000 --- a/plugins/modules/rax_cbs.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_cbs -short_description: Manipulate Rackspace Cloud Block Storage Volumes -description: - - Manipulate Rackspace Cloud Block Storage Volumes -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - description: - type: str - description: - - Description to give the volume being created. - image: - type: str - description: - - Image to use for bootable volumes. Can be an C(id), C(human_id) or - C(name). This option requires C(pyrax>=1.9.3). - meta: - type: dict - default: {} - description: - - A hash of metadata to associate with the volume. - name: - type: str - description: - - Name to give the volume being created. - required: true - size: - type: int - description: - - Size of the volume to create in Gigabytes. - default: 100 - snapshot_id: - type: str - description: - - The id of the snapshot to create the volume from. - state: - type: str - description: - - Indicate desired state of the resource. - choices: - - present - - absent - default: present - volume_type: - type: str - description: - - Type of the volume being created. - choices: - - SATA - - SSD - default: SATA - wait: - description: - - Wait for the volume to be in state C(available) before returning. - type: bool - default: false - wait_timeout: - type: int - description: - - how long before wait gives up, in seconds. - default: 300 -author: - - "Christopher H. Laco (@claco)" - - "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a Block Storage Volume - gather_facts: false - hosts: local - connection: local - tasks: - - name: Storage volume create request - local_action: - module: rax_cbs - credentials: ~/.raxpub - name: my-volume - description: My Volume - volume_type: SSD - size: 150 - region: DFW - wait: true - state: present - meta: - app: my-cool-app - register: my_volume -''' - -from ansible_collections.community.general.plugins.module_utils.version import LooseVersion - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (VOLUME_STATUS, rax_argument_spec, rax_find_image, rax_find_volume, - rax_required_together, rax_to_dict, setup_rax_module) - - -def cloud_block_storage(module, state, name, description, meta, size, - snapshot_id, volume_type, wait, wait_timeout, - image): - changed = False - volume = None - instance = {} - - cbs = pyrax.cloud_blockstorage - - if cbs is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if image: - # pyrax<1.9.3 did not have support for specifying an image when - # creating a volume which is required for bootable volumes - if LooseVersion(pyrax.version.version) < LooseVersion('1.9.3'): - module.fail_json(msg='Creating a bootable volume requires ' - 'pyrax>=1.9.3') - image = rax_find_image(module, pyrax, image) - - volume = rax_find_volume(module, pyrax, name) - - if state == 'present': - if not volume: - kwargs = dict() - if image: - kwargs['image'] = image - try: - volume = cbs.create(name, size=size, volume_type=volume_type, - description=description, - metadata=meta, - snapshot_id=snapshot_id, **kwargs) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - if wait: - attempts = wait_timeout // 5 - pyrax.utils.wait_for_build(volume, interval=5, - attempts=attempts) - - volume.get() - instance = rax_to_dict(volume) - - result = dict(changed=changed, volume=instance) - - if volume.status == 'error': - result['msg'] = '%s failed to build' % volume.id - elif wait and volume.status not in VOLUME_STATUS: - result['msg'] = 'Timeout waiting on %s' % volume.id - - if 'msg' in result: - module.fail_json(**result) - else: - module.exit_json(**result) - - elif state == 'absent': - if volume: - instance = rax_to_dict(volume) - try: - volume.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, volume=instance) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - description=dict(type='str'), - image=dict(type='str'), - meta=dict(type='dict', default={}), - name=dict(required=True), - size=dict(type='int', default=100), - snapshot_id=dict(), - state=dict(default='present', choices=['present', 'absent']), - volume_type=dict(choices=['SSD', 'SATA'], default='SATA'), - wait=dict(type='bool', default=False), - wait_timeout=dict(type='int', default=300) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - description = module.params.get('description') - image = module.params.get('image') - meta = module.params.get('meta') - name = module.params.get('name') - size = module.params.get('size') - snapshot_id = module.params.get('snapshot_id') - state = module.params.get('state') - volume_type = module.params.get('volume_type') - wait = module.params.get('wait') - wait_timeout = module.params.get('wait_timeout') - - setup_rax_module(module, pyrax) - - cloud_block_storage(module, state, name, description, meta, size, - snapshot_id, volume_type, wait, wait_timeout, - image) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_cbs_attachments.py b/plugins/modules/rax_cbs_attachments.py deleted file mode 100644 index 00b860a90f..0000000000 --- a/plugins/modules/rax_cbs_attachments.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_cbs_attachments -short_description: Manipulate Rackspace Cloud Block Storage Volume Attachments -description: - - Manipulate Rackspace Cloud Block Storage Volume Attachments -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - device: - type: str - description: - - The device path to attach the volume to, e.g. /dev/xvde. - - Before 2.4 this was a required field. Now it can be left to null to auto assign the device name. - volume: - type: str - description: - - Name or id of the volume to attach/detach - required: true - server: - type: str - description: - - Name or id of the server to attach/detach - required: true - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - wait: - description: - - wait for the volume to be in 'in-use'/'available' state before returning - type: bool - default: false - wait_timeout: - type: int - description: - - how long before wait gives up, in seconds - default: 300 -author: - - "Christopher H. Laco (@claco)" - - "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Attach a Block Storage Volume - gather_facts: false - hosts: local - connection: local - tasks: - - name: Storage volume attach request - local_action: - module: rax_cbs_attachments - credentials: ~/.raxpub - volume: my-volume - server: my-server - device: /dev/xvdd - region: DFW - wait: true - state: present - register: my_volume -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (NON_CALLABLES, - rax_argument_spec, - rax_find_server, - rax_find_volume, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def cloud_block_storage_attachments(module, state, volume, server, device, - wait, wait_timeout): - cbs = pyrax.cloud_blockstorage - cs = pyrax.cloudservers - - if cbs is None or cs is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - changed = False - instance = {} - - volume = rax_find_volume(module, pyrax, volume) - - if not volume: - module.fail_json(msg='No matching storage volumes were found') - - if state == 'present': - server = rax_find_server(module, pyrax, server) - - if (volume.attachments and - volume.attachments[0]['server_id'] == server.id): - changed = False - elif volume.attachments: - module.fail_json(msg='Volume is attached to another server') - else: - try: - volume.attach_to_instance(server, mountpoint=device) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - volume.get() - - for key, value in vars(volume).items(): - if (isinstance(value, NON_CALLABLES) and - not key.startswith('_')): - instance[key] = value - - result = dict(changed=changed) - - if volume.status == 'error': - result['msg'] = '%s failed to build' % volume.id - elif wait: - attempts = wait_timeout // 5 - pyrax.utils.wait_until(volume, 'status', 'in-use', - interval=5, attempts=attempts) - - volume.get() - result['volume'] = rax_to_dict(volume) - - if 'msg' in result: - module.fail_json(**result) - else: - module.exit_json(**result) - - elif state == 'absent': - server = rax_find_server(module, pyrax, server) - - if (volume.attachments and - volume.attachments[0]['server_id'] == server.id): - try: - volume.detach() - if wait: - pyrax.utils.wait_until(volume, 'status', 'available', - interval=3, attempts=0, - verbose=False) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - volume.get() - changed = True - elif volume.attachments: - module.fail_json(msg='Volume is attached to another server') - - result = dict(changed=changed, volume=rax_to_dict(volume)) - - if volume.status == 'error': - result['msg'] = '%s failed to build' % volume.id - - if 'msg' in result: - module.fail_json(**result) - else: - module.exit_json(**result) - - module.exit_json(changed=changed, volume=instance) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - device=dict(required=False), - volume=dict(required=True), - server=dict(required=True), - state=dict(default='present', choices=['present', 'absent']), - wait=dict(type='bool', default=False), - wait_timeout=dict(type='int', default=300) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - device = module.params.get('device') - volume = module.params.get('volume') - server = module.params.get('server') - state = module.params.get('state') - wait = module.params.get('wait') - wait_timeout = module.params.get('wait_timeout') - - setup_rax_module(module, pyrax) - - cloud_block_storage_attachments(module, state, volume, server, device, - wait, wait_timeout) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_cdb.py b/plugins/modules/rax_cdb.py deleted file mode 100644 index 9538579fad..0000000000 --- a/plugins/modules/rax_cdb.py +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_cdb -short_description: Create/delete or resize a Rackspace Cloud Databases instance -description: - - creates / deletes or resize a Rackspace Cloud Databases instance - and optionally waits for it to be 'running'. The name option needs to be - unique since it's used to identify the instance. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - name: - type: str - description: - - Name of the databases server instance - required: true - flavor: - type: int - description: - - flavor to use for the instance 1 to 6 (i.e. 512MB to 16GB) - default: 1 - volume: - type: int - description: - - Volume size of the database 1-150GB - default: 2 - cdb_type: - type: str - description: - - type of instance (i.e. MySQL, MariaDB, Percona) - default: MySQL - aliases: ['type'] - cdb_version: - type: str - description: - - version of database (MySQL supports 5.1 and 5.6, MariaDB supports 10, Percona supports 5.6) - - "The available choices are: V(5.1), V(5.6) and V(10)." - default: '5.6' - aliases: ['version'] - state: - type: str - description: - - Indicate desired state of the resource - choices: ['present', 'absent'] - default: present - wait: - description: - - wait for the instance to be in state 'running' before returning - type: bool - default: false - wait_timeout: - type: int - description: - - how long before wait gives up, in seconds - default: 300 -author: "Simon JAILLET (@jails)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a Cloud Databases - gather_facts: false - tasks: - - name: Server build request - local_action: - module: rax_cdb - credentials: ~/.raxpub - region: IAD - name: db-server1 - flavor: 1 - volume: 2 - cdb_type: MySQL - cdb_version: 5.6 - wait: true - state: present - register: rax_db_server -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, rax_to_dict, setup_rax_module - - -def find_instance(name): - - cdb = pyrax.cloud_databases - instances = cdb.list() - if instances: - for instance in instances: - if instance.name == name: - return instance - return False - - -def save_instance(module, name, flavor, volume, cdb_type, cdb_version, wait, - wait_timeout): - - for arg, value in dict(name=name, flavor=flavor, - volume=volume, type=cdb_type, version=cdb_version - ).items(): - if not value: - module.fail_json(msg='%s is required for the "rax_cdb"' - ' module' % arg) - - if not (volume >= 1 and volume <= 150): - module.fail_json(msg='volume is required to be between 1 and 150') - - cdb = pyrax.cloud_databases - - flavors = [] - for item in cdb.list_flavors(): - flavors.append(item.id) - - if not (flavor in flavors): - module.fail_json(msg='unexisting flavor reference "%s"' % str(flavor)) - - changed = False - - instance = find_instance(name) - - if not instance: - action = 'create' - try: - instance = cdb.create(name=name, flavor=flavor, volume=volume, - type=cdb_type, version=cdb_version) - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - - else: - action = None - - if instance.volume.size != volume: - action = 'resize' - if instance.volume.size > volume: - module.fail_json(changed=False, action=action, - msg='The new volume size must be larger than ' - 'the current volume size', - cdb=rax_to_dict(instance)) - instance.resize_volume(volume) - changed = True - - if int(instance.flavor.id) != flavor: - action = 'resize' - pyrax.utils.wait_until(instance, 'status', 'ACTIVE', - attempts=wait_timeout) - instance.resize(flavor) - changed = True - - if wait: - pyrax.utils.wait_until(instance, 'status', 'ACTIVE', - attempts=wait_timeout) - - if wait and instance.status != 'ACTIVE': - module.fail_json(changed=changed, action=action, - cdb=rax_to_dict(instance), - msg='Timeout waiting for "%s" databases instance to ' - 'be created' % name) - - module.exit_json(changed=changed, action=action, cdb=rax_to_dict(instance)) - - -def delete_instance(module, name, wait, wait_timeout): - - if not name: - module.fail_json(msg='name is required for the "rax_cdb" module') - - changed = False - - instance = find_instance(name) - if not instance: - module.exit_json(changed=False, action='delete') - - try: - instance.delete() - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - - if wait: - pyrax.utils.wait_until(instance, 'status', 'SHUTDOWN', - attempts=wait_timeout) - - if wait and instance.status != 'SHUTDOWN': - module.fail_json(changed=changed, action='delete', - cdb=rax_to_dict(instance), - msg='Timeout waiting for "%s" databases instance to ' - 'be deleted' % name) - - module.exit_json(changed=changed, action='delete', - cdb=rax_to_dict(instance)) - - -def rax_cdb(module, state, name, flavor, volume, cdb_type, cdb_version, wait, - wait_timeout): - - # act on the state - if state == 'present': - save_instance(module, name, flavor, volume, cdb_type, cdb_version, wait, - wait_timeout) - elif state == 'absent': - delete_instance(module, name, wait, wait_timeout) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - name=dict(type='str', required=True), - flavor=dict(type='int', default=1), - volume=dict(type='int', default=2), - cdb_type=dict(type='str', default='MySQL', aliases=['type']), - cdb_version=dict(type='str', default='5.6', aliases=['version']), - state=dict(default='present', choices=['present', 'absent']), - wait=dict(type='bool', default=False), - wait_timeout=dict(type='int', default=300), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - name = module.params.get('name') - flavor = module.params.get('flavor') - volume = module.params.get('volume') - cdb_type = module.params.get('cdb_type') - cdb_version = module.params.get('cdb_version') - state = module.params.get('state') - wait = module.params.get('wait') - wait_timeout = module.params.get('wait_timeout') - - setup_rax_module(module, pyrax) - rax_cdb(module, state, name, flavor, volume, cdb_type, cdb_version, wait, wait_timeout) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_cdb_database.py b/plugins/modules/rax_cdb_database.py deleted file mode 100644 index b0db11814d..0000000000 --- a/plugins/modules/rax_cdb_database.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' -module: rax_cdb_database -short_description: Create / delete a database in the Cloud Databases -description: - - create / delete a database in the Cloud Databases. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - cdb_id: - type: str - description: - - The databases server UUID - required: true - name: - type: str - description: - - Name to give to the database - required: true - character_set: - type: str - description: - - Set of symbols and encodings - default: 'utf8' - collate: - type: str - description: - - Set of rules for comparing characters in a character set - default: 'utf8_general_ci' - state: - type: str - description: - - Indicate desired state of the resource - choices: ['present', 'absent'] - default: present -author: "Simon JAILLET (@jails)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a database in Cloud Databases - tasks: - - name: Database build request - local_action: - module: rax_cdb_database - credentials: ~/.raxpub - region: IAD - cdb_id: 323e7ce0-9cb0-11e3-a5e2-0800200c9a66 - name: db1 - state: present - register: rax_db_database -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, rax_to_dict, setup_rax_module - - -def find_database(instance, name): - try: - database = instance.get_database(name) - except Exception: - return False - - return database - - -def save_database(module, cdb_id, name, character_set, collate): - cdb = pyrax.cloud_databases - - try: - instance = cdb.get(cdb_id) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - changed = False - - database = find_database(instance, name) - - if not database: - try: - database = instance.create_database(name=name, - character_set=character_set, - collate=collate) - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - - module.exit_json(changed=changed, action='create', - database=rax_to_dict(database)) - - -def delete_database(module, cdb_id, name): - cdb = pyrax.cloud_databases - - try: - instance = cdb.get(cdb_id) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - changed = False - - database = find_database(instance, name) - - if database: - try: - database.delete() - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - - module.exit_json(changed=changed, action='delete', - database=rax_to_dict(database)) - - -def rax_cdb_database(module, state, cdb_id, name, character_set, collate): - - # act on the state - if state == 'present': - save_database(module, cdb_id, name, character_set, collate) - elif state == 'absent': - delete_database(module, cdb_id, name) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - cdb_id=dict(type='str', required=True), - name=dict(type='str', required=True), - character_set=dict(type='str', default='utf8'), - collate=dict(type='str', default='utf8_general_ci'), - state=dict(default='present', choices=['present', 'absent']) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - cdb_id = module.params.get('cdb_id') - name = module.params.get('name') - character_set = module.params.get('character_set') - collate = module.params.get('collate') - state = module.params.get('state') - - setup_rax_module(module, pyrax) - rax_cdb_database(module, state, cdb_id, name, character_set, collate) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_cdb_user.py b/plugins/modules/rax_cdb_user.py deleted file mode 100644 index 6ee86c4fe2..0000000000 --- a/plugins/modules/rax_cdb_user.py +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_cdb_user -short_description: Create / delete a Rackspace Cloud Database -description: - - create / delete a database in the Cloud Databases. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - cdb_id: - type: str - description: - - The databases server UUID - required: true - db_username: - type: str - description: - - Name of the database user - required: true - db_password: - type: str - description: - - Database user password - required: true - databases: - type: list - elements: str - description: - - Name of the databases that the user can access - default: [] - host: - type: str - description: - - Specifies the host from which a user is allowed to connect to - the database. Possible values are a string containing an IPv4 address - or "%" to allow connecting from any host - default: '%' - state: - type: str - description: - - Indicate desired state of the resource - choices: ['present', 'absent'] - default: present -author: "Simon JAILLET (@jails)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a user in Cloud Databases - tasks: - - name: User build request - local_action: - module: rax_cdb_user - credentials: ~/.raxpub - region: IAD - cdb_id: 323e7ce0-9cb0-11e3-a5e2-0800200c9a66 - db_username: user1 - db_password: user1 - databases: ['db1'] - state: present - register: rax_db_user -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.common.text.converters import to_text -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, rax_to_dict, setup_rax_module - - -def find_user(instance, name): - try: - user = instance.get_user(name) - except Exception: - return False - - return user - - -def save_user(module, cdb_id, name, password, databases, host): - - for arg, value in dict(cdb_id=cdb_id, name=name).items(): - if not value: - module.fail_json(msg='%s is required for the "rax_cdb_user" ' - 'module' % arg) - - cdb = pyrax.cloud_databases - - try: - instance = cdb.get(cdb_id) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - changed = False - - user = find_user(instance, name) - - if not user: - action = 'create' - try: - user = instance.create_user(name=name, - password=password, - database_names=databases, - host=host) - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - else: - action = 'update' - - if user.host != host: - changed = True - - user.update(password=password, host=host) - - former_dbs = set([item.name for item in user.list_user_access()]) - databases = set(databases) - - if databases != former_dbs: - try: - revoke_dbs = [db for db in former_dbs if db not in databases] - user.revoke_user_access(db_names=revoke_dbs) - - new_dbs = [db for db in databases if db not in former_dbs] - user.grant_user_access(db_names=new_dbs) - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - - module.exit_json(changed=changed, action=action, user=rax_to_dict(user)) - - -def delete_user(module, cdb_id, name): - - for arg, value in dict(cdb_id=cdb_id, name=name).items(): - if not value: - module.fail_json(msg='%s is required for the "rax_cdb_user"' - ' module' % arg) - - cdb = pyrax.cloud_databases - - try: - instance = cdb.get(cdb_id) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - changed = False - - user = find_user(instance, name) - - if user: - try: - user.delete() - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - changed = True - - module.exit_json(changed=changed, action='delete') - - -def rax_cdb_user(module, state, cdb_id, name, password, databases, host): - - # act on the state - if state == 'present': - save_user(module, cdb_id, name, password, databases, host) - elif state == 'absent': - delete_user(module, cdb_id, name) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - cdb_id=dict(type='str', required=True), - db_username=dict(type='str', required=True), - db_password=dict(type='str', required=True, no_log=True), - databases=dict(type='list', elements='str', default=[]), - host=dict(type='str', default='%'), - state=dict(default='present', choices=['present', 'absent']) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - cdb_id = module.params.get('cdb_id') - name = module.params.get('db_username') - password = module.params.get('db_password') - databases = module.params.get('databases') - host = to_text(module.params.get('host'), errors='surrogate_or_strict') - state = module.params.get('state') - - setup_rax_module(module, pyrax) - rax_cdb_user(module, state, cdb_id, name, password, databases, host) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_clb.py b/plugins/modules/rax_clb.py deleted file mode 100644 index 23c795f395..0000000000 --- a/plugins/modules/rax_clb.py +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_clb -short_description: Create / delete a load balancer in Rackspace Public Cloud -description: - - creates / deletes a Rackspace Public Cloud load balancer. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - algorithm: - type: str - description: - - algorithm for the balancer being created - choices: - - RANDOM - - LEAST_CONNECTIONS - - ROUND_ROBIN - - WEIGHTED_LEAST_CONNECTIONS - - WEIGHTED_ROUND_ROBIN - default: LEAST_CONNECTIONS - meta: - type: dict - default: {} - description: - - A hash of metadata to associate with the instance - name: - type: str - description: - - Name to give the load balancer - required: true - port: - type: int - description: - - Port for the balancer being created - default: 80 - protocol: - type: str - description: - - Protocol for the balancer being created - choices: - - DNS_TCP - - DNS_UDP - - FTP - - HTTP - - HTTPS - - IMAPS - - IMAPv4 - - LDAP - - LDAPS - - MYSQL - - POP3 - - POP3S - - SMTP - - TCP - - TCP_CLIENT_FIRST - - UDP - - UDP_STREAM - - SFTP - default: HTTP - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - timeout: - type: int - description: - - timeout for communication between the balancer and the node - default: 30 - type: - type: str - description: - - type of interface for the balancer being created - choices: - - PUBLIC - - SERVICENET - default: PUBLIC - vip_id: - type: str - description: - - Virtual IP ID to use when creating the load balancer for purposes of - sharing an IP with another load balancer of another protocol - wait: - description: - - wait for the balancer to be in state 'running' before returning - type: bool - default: false - wait_timeout: - type: int - description: - - how long before wait gives up, in seconds - default: 300 -author: - - "Christopher H. Laco (@claco)" - - "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a Load Balancer - gather_facts: false - hosts: local - connection: local - tasks: - - name: Load Balancer create request - local_action: - module: rax_clb - credentials: ~/.raxpub - name: my-lb - port: 8080 - protocol: HTTP - type: SERVICENET - timeout: 30 - region: DFW - wait: true - state: present - meta: - app: my-cool-app - register: my_lb -''' - - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (CLB_ALGORITHMS, - CLB_PROTOCOLS, - rax_argument_spec, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def cloud_load_balancer(module, state, name, meta, algorithm, port, protocol, - vip_type, timeout, wait, wait_timeout, vip_id): - if int(timeout) < 30: - module.fail_json(msg='"timeout" must be greater than or equal to 30') - - changed = False - balancers = [] - - clb = pyrax.cloud_loadbalancers - if not clb: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - balancer_list = clb.list() - while balancer_list: - retrieved = clb.list(marker=balancer_list.pop().id) - balancer_list.extend(retrieved) - if len(retrieved) < 2: - break - - for balancer in balancer_list: - if name != balancer.name and name != balancer.id: - continue - - balancers.append(balancer) - - if len(balancers) > 1: - module.fail_json(msg='Multiple Load Balancers were matched by name, ' - 'try using the Load Balancer ID instead') - - if state == 'present': - if isinstance(meta, dict): - metadata = [dict(key=k, value=v) for k, v in meta.items()] - - if not balancers: - try: - virtual_ips = [clb.VirtualIP(type=vip_type, id=vip_id)] - balancer = clb.create(name, metadata=metadata, port=port, - algorithm=algorithm, protocol=protocol, - timeout=timeout, virtual_ips=virtual_ips) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - balancer = balancers[0] - setattr(balancer, 'metadata', - [dict(key=k, value=v) for k, v in - balancer.get_metadata().items()]) - atts = { - 'name': name, - 'algorithm': algorithm, - 'port': port, - 'protocol': protocol, - 'timeout': timeout - } - for att, value in atts.items(): - current = getattr(balancer, att) - if current != value: - changed = True - - if changed: - balancer.update(**atts) - - if balancer.metadata != metadata: - balancer.set_metadata(meta) - changed = True - - virtual_ips = [clb.VirtualIP(type=vip_type)] - current_vip_types = set([v.type for v in balancer.virtual_ips]) - vip_types = set([v.type for v in virtual_ips]) - if current_vip_types != vip_types: - module.fail_json(msg='Load balancer Virtual IP type cannot ' - 'be changed') - - if wait: - attempts = wait_timeout // 5 - pyrax.utils.wait_for_build(balancer, interval=5, attempts=attempts) - - balancer.get() - instance = rax_to_dict(balancer, 'clb') - - result = dict(changed=changed, balancer=instance) - - if balancer.status == 'ERROR': - result['msg'] = '%s failed to build' % balancer.id - elif wait and balancer.status not in ('ACTIVE', 'ERROR'): - result['msg'] = 'Timeout waiting on %s' % balancer.id - - if 'msg' in result: - module.fail_json(**result) - else: - module.exit_json(**result) - - elif state == 'absent': - if balancers: - balancer = balancers[0] - try: - balancer.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - instance = rax_to_dict(balancer, 'clb') - - if wait: - attempts = wait_timeout // 5 - pyrax.utils.wait_until(balancer, 'status', ('DELETED'), - interval=5, attempts=attempts) - else: - instance = {} - - module.exit_json(changed=changed, balancer=instance) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - algorithm=dict(choices=CLB_ALGORITHMS, - default='LEAST_CONNECTIONS'), - meta=dict(type='dict', default={}), - name=dict(required=True), - port=dict(type='int', default=80), - protocol=dict(choices=CLB_PROTOCOLS, default='HTTP'), - state=dict(default='present', choices=['present', 'absent']), - timeout=dict(type='int', default=30), - type=dict(choices=['PUBLIC', 'SERVICENET'], default='PUBLIC'), - vip_id=dict(), - wait=dict(type='bool', default=False), - wait_timeout=dict(type='int', default=300), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - algorithm = module.params.get('algorithm') - meta = module.params.get('meta') - name = module.params.get('name') - port = module.params.get('port') - protocol = module.params.get('protocol') - state = module.params.get('state') - timeout = int(module.params.get('timeout')) - vip_id = module.params.get('vip_id') - vip_type = module.params.get('type') - wait = module.params.get('wait') - wait_timeout = int(module.params.get('wait_timeout')) - - setup_rax_module(module, pyrax) - - cloud_load_balancer(module, state, name, meta, algorithm, port, protocol, - vip_type, timeout, wait, wait_timeout, vip_id) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_clb_nodes.py b/plugins/modules/rax_clb_nodes.py deleted file mode 100644 index c076dced74..0000000000 --- a/plugins/modules/rax_clb_nodes.py +++ /dev/null @@ -1,291 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_clb_nodes -short_description: Add, modify and remove nodes from a Rackspace Cloud Load Balancer -description: - - Adds, modifies and removes nodes from a Rackspace Cloud Load Balancer -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - address: - type: str - required: false - description: - - IP address or domain name of the node - condition: - type: str - required: false - choices: - - enabled - - disabled - - draining - description: - - Condition for the node, which determines its role within the load - balancer - load_balancer_id: - type: int - required: true - description: - - Load balancer id - node_id: - type: int - required: false - description: - - Node id - port: - type: int - required: false - description: - - Port number of the load balanced service on the node - state: - type: str - required: false - default: "present" - choices: - - present - - absent - description: - - Indicate desired state of the node - type: - type: str - required: false - choices: - - primary - - secondary - description: - - Type of node - wait: - required: false - default: false - type: bool - description: - - Wait for the load balancer to become active before returning - wait_timeout: - type: int - required: false - default: 30 - description: - - How long to wait before giving up and returning an error - weight: - type: int - required: false - description: - - Weight of node - virtualenv: - type: path - description: - - Virtualenv to execute this module in -author: "Lukasz Kawczynski (@neuroid)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Add a new node to the load balancer - local_action: - module: rax_clb_nodes - load_balancer_id: 71 - address: 10.2.2.3 - port: 80 - condition: enabled - type: primary - wait: true - credentials: /path/to/credentials - -- name: Drain connections from a node - local_action: - module: rax_clb_nodes - load_balancer_id: 71 - node_id: 410 - condition: draining - wait: true - credentials: /path/to/credentials - -- name: Remove a node from the load balancer - local_action: - module: rax_clb_nodes - load_balancer_id: 71 - node_id: 410 - state: absent - wait: true - credentials: /path/to/credentials -''' - -import os - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_clb_node_to_dict, rax_required_together, setup_rax_module - - -def _activate_virtualenv(path): - activate_this = os.path.join(path, 'bin', 'activate_this.py') - with open(activate_this) as f: - code = compile(f.read(), activate_this, 'exec') - exec(code) - - -def _get_node(lb, node_id=None, address=None, port=None): - """Return a matching node""" - for node in getattr(lb, 'nodes', []): - match_list = [] - if node_id is not None: - match_list.append(getattr(node, 'id', None) == node_id) - if address is not None: - match_list.append(getattr(node, 'address', None) == address) - if port is not None: - match_list.append(getattr(node, 'port', None) == port) - - if match_list and all(match_list): - return node - - return None - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - address=dict(), - condition=dict(choices=['enabled', 'disabled', 'draining']), - load_balancer_id=dict(required=True, type='int'), - node_id=dict(type='int'), - port=dict(type='int'), - state=dict(default='present', choices=['present', 'absent']), - type=dict(choices=['primary', 'secondary']), - virtualenv=dict(type='path'), - wait=dict(default=False, type='bool'), - wait_timeout=dict(default=30, type='int'), - weight=dict(type='int'), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - address = module.params['address'] - condition = (module.params['condition'] and - module.params['condition'].upper()) - load_balancer_id = module.params['load_balancer_id'] - node_id = module.params['node_id'] - port = module.params['port'] - state = module.params['state'] - typ = module.params['type'] and module.params['type'].upper() - virtualenv = module.params['virtualenv'] - wait = module.params['wait'] - wait_timeout = module.params['wait_timeout'] or 1 - weight = module.params['weight'] - - if virtualenv: - try: - _activate_virtualenv(virtualenv) - except IOError as e: - module.fail_json(msg='Failed to activate virtualenv %s (%s)' % ( - virtualenv, e)) - - setup_rax_module(module, pyrax) - - if not pyrax.cloud_loadbalancers: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - try: - lb = pyrax.cloud_loadbalancers.get(load_balancer_id) - except pyrax.exc.PyraxException as e: - module.fail_json(msg='%s' % e.message) - - node = _get_node(lb, node_id, address, port) - - result = rax_clb_node_to_dict(node) - - if state == 'absent': - if not node: # Removing a non-existent node - module.exit_json(changed=False, state=state) - try: - lb.delete_node(node) - result = {} - except pyrax.exc.NotFound: - module.exit_json(changed=False, state=state) - except pyrax.exc.PyraxException as e: - module.fail_json(msg='%s' % e.message) - else: # present - if not node: - if node_id: # Updating a non-existent node - msg = 'Node %d not found' % node_id - if lb.nodes: - msg += (' (available nodes: %s)' % - ', '.join([str(x.id) for x in lb.nodes])) - module.fail_json(msg=msg) - else: # Creating a new node - try: - node = pyrax.cloudloadbalancers.Node( - address=address, port=port, condition=condition, - weight=weight, type=typ) - resp, body = lb.add_nodes([node]) - result.update(body['nodes'][0]) - except pyrax.exc.PyraxException as e: - module.fail_json(msg='%s' % e.message) - else: # Updating an existing node - mutable = { - 'condition': condition, - 'type': typ, - 'weight': weight, - } - - for name in list(mutable): - value = mutable[name] - if value is None or value == getattr(node, name): - mutable.pop(name) - - if not mutable: - module.exit_json(changed=False, state=state, node=result) - - try: - # The diff has to be set explicitly to update node's weight and - # type; this should probably be fixed in pyrax - lb.update_node(node, diff=mutable) - result.update(mutable) - except pyrax.exc.PyraxException as e: - module.fail_json(msg='%s' % e.message) - - if wait: - pyrax.utils.wait_until(lb, "status", "ACTIVE", interval=1, - attempts=wait_timeout) - if lb.status != 'ACTIVE': - module.fail_json( - msg='Load balancer not active after %ds (current status: %s)' % - (wait_timeout, lb.status.lower())) - - kwargs = {'node': result} if result else {} - module.exit_json(changed=True, state=state, **kwargs) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_clb_ssl.py b/plugins/modules/rax_clb_ssl.py deleted file mode 100644 index b794130cfa..0000000000 --- a/plugins/modules/rax_clb_ssl.py +++ /dev/null @@ -1,289 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' -module: rax_clb_ssl -short_description: Manage SSL termination for a Rackspace Cloud Load Balancer -description: -- Set up, reconfigure, or remove SSL termination for an existing load balancer. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - loadbalancer: - type: str - description: - - Name or ID of the load balancer on which to manage SSL termination. - required: true - state: - type: str - description: - - If set to "present", SSL termination will be added to this load balancer. - - If "absent", SSL termination will be removed instead. - choices: - - present - - absent - default: present - enabled: - description: - - If set to "false", temporarily disable SSL termination without discarding - - existing credentials. - default: true - type: bool - private_key: - type: str - description: - - The private SSL key as a string in PEM format. - certificate: - type: str - description: - - The public SSL certificates as a string in PEM format. - intermediate_certificate: - type: str - description: - - One or more intermediate certificate authorities as a string in PEM - - format, concatenated into a single string. - secure_port: - type: int - description: - - The port to listen for secure traffic. - default: 443 - secure_traffic_only: - description: - - If "true", the load balancer will *only* accept secure traffic. - default: false - type: bool - https_redirect: - description: - - If "true", the load balancer will redirect HTTP traffic to HTTPS. - - Requires "secure_traffic_only" to be true. Incurs an implicit wait if SSL - - termination is also applied or removed. - type: bool - wait: - description: - - Wait for the balancer to be in state "running" before turning. - default: false - type: bool - wait_timeout: - type: int - description: - - How long before "wait" gives up, in seconds. - default: 300 -author: Ash Wilson (@smashwilson) -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Enable SSL termination on a load balancer - community.general.rax_clb_ssl: - loadbalancer: the_loadbalancer - state: present - private_key: "{{ lookup('file', 'credentials/server.key' ) }}" - certificate: "{{ lookup('file', 'credentials/server.crt' ) }}" - intermediate_certificate: "{{ lookup('file', 'credentials/trust-chain.crt') }}" - secure_traffic_only: true - wait: true - -- name: Disable SSL termination - community.general.rax_clb_ssl: - loadbalancer: "{{ registered_lb.balancer.id }}" - state: absent - wait: true -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (rax_argument_spec, - rax_find_loadbalancer, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def cloud_load_balancer_ssl(module, loadbalancer, state, enabled, private_key, - certificate, intermediate_certificate, secure_port, - secure_traffic_only, https_redirect, - wait, wait_timeout): - # Validate arguments. - - if state == 'present': - if not private_key: - module.fail_json(msg="private_key must be provided.") - else: - private_key = private_key.strip() - - if not certificate: - module.fail_json(msg="certificate must be provided.") - else: - certificate = certificate.strip() - - attempts = wait_timeout // 5 - - # Locate the load balancer. - - balancer = rax_find_loadbalancer(module, pyrax, loadbalancer) - existing_ssl = balancer.get_ssl_termination() - - changed = False - - if state == 'present': - # Apply or reconfigure SSL termination on the load balancer. - ssl_attrs = dict( - securePort=secure_port, - privatekey=private_key, - certificate=certificate, - intermediateCertificate=intermediate_certificate, - enabled=enabled, - secureTrafficOnly=secure_traffic_only - ) - - needs_change = False - - if existing_ssl: - for ssl_attr, value in ssl_attrs.items(): - if ssl_attr == 'privatekey': - # The private key is not included in get_ssl_termination's - # output (as it shouldn't be). Also, if you're changing the - # private key, you'll also be changing the certificate, - # so we don't lose anything by not checking it. - continue - - if value is not None and existing_ssl.get(ssl_attr) != value: - # module.fail_json(msg='Unnecessary change', attr=ssl_attr, value=value, existing=existing_ssl.get(ssl_attr)) - needs_change = True - else: - needs_change = True - - if needs_change: - try: - balancer.add_ssl_termination(**ssl_attrs) - except pyrax.exceptions.PyraxException as e: - module.fail_json(msg='%s' % e.message) - changed = True - elif state == 'absent': - # Remove SSL termination if it's already configured. - if existing_ssl: - try: - balancer.delete_ssl_termination() - except pyrax.exceptions.PyraxException as e: - module.fail_json(msg='%s' % e.message) - changed = True - - if https_redirect is not None and balancer.httpsRedirect != https_redirect: - if changed: - # This wait is unavoidable because load balancers are immutable - # while the SSL termination changes above are being applied. - pyrax.utils.wait_for_build(balancer, interval=5, attempts=attempts) - - try: - balancer.update(httpsRedirect=https_redirect) - except pyrax.exceptions.PyraxException as e: - module.fail_json(msg='%s' % e.message) - changed = True - - if changed and wait: - pyrax.utils.wait_for_build(balancer, interval=5, attempts=attempts) - - balancer.get() - new_ssl_termination = balancer.get_ssl_termination() - - # Intentionally omit the private key from the module output, so you don't - # accidentally echo it with `ansible-playbook -v` or `debug`, and the - # certificate, which is just long. Convert other attributes to snake_case - # and include https_redirect at the top-level. - if new_ssl_termination: - new_ssl = dict( - enabled=new_ssl_termination['enabled'], - secure_port=new_ssl_termination['securePort'], - secure_traffic_only=new_ssl_termination['secureTrafficOnly'] - ) - else: - new_ssl = None - - result = dict( - changed=changed, - https_redirect=balancer.httpsRedirect, - ssl_termination=new_ssl, - balancer=rax_to_dict(balancer, 'clb') - ) - success = True - - if balancer.status == 'ERROR': - result['msg'] = '%s failed to build' % balancer.id - success = False - elif wait and balancer.status not in ('ACTIVE', 'ERROR'): - result['msg'] = 'Timeout waiting on %s' % balancer.id - success = False - - if success: - module.exit_json(**result) - else: - module.fail_json(**result) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update(dict( - loadbalancer=dict(required=True), - state=dict(default='present', choices=['present', 'absent']), - enabled=dict(type='bool', default=True), - private_key=dict(no_log=True), - certificate=dict(), - intermediate_certificate=dict(), - secure_port=dict(type='int', default=443), - secure_traffic_only=dict(type='bool', default=False), - https_redirect=dict(type='bool'), - wait=dict(type='bool', default=False), - wait_timeout=dict(type='int', default=300) - )) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module.') - - loadbalancer = module.params.get('loadbalancer') - state = module.params.get('state') - enabled = module.boolean(module.params.get('enabled')) - private_key = module.params.get('private_key') - certificate = module.params.get('certificate') - intermediate_certificate = module.params.get('intermediate_certificate') - secure_port = module.params.get('secure_port') - secure_traffic_only = module.boolean(module.params.get('secure_traffic_only')) - https_redirect = module.boolean(module.params.get('https_redirect')) - wait = module.boolean(module.params.get('wait')) - wait_timeout = module.params.get('wait_timeout') - - setup_rax_module(module, pyrax) - - cloud_load_balancer_ssl( - module, loadbalancer, state, enabled, private_key, certificate, - intermediate_certificate, secure_port, secure_traffic_only, - https_redirect, wait, wait_timeout - ) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_dns.py b/plugins/modules/rax_dns.py deleted file mode 100644 index 31782cd882..0000000000 --- a/plugins/modules/rax_dns.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_dns -short_description: Manage domains on Rackspace Cloud DNS -description: - - Manage domains on Rackspace Cloud DNS -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - comment: - type: str - description: - - Brief description of the domain. Maximum length of 160 characters - email: - type: str - description: - - Email address of the domain administrator - name: - type: str - description: - - Domain name to create - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - ttl: - type: int - description: - - Time to live of domain in seconds - default: 3600 -notes: - - "It is recommended that plays utilizing this module be run with - C(serial: 1) to avoid exceeding the API request limit imposed by - the Rackspace CloudDNS API" -author: "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Create domain - hosts: all - gather_facts: false - tasks: - - name: Domain create request - local_action: - module: rax_dns - credentials: ~/.raxpub - name: example.org - email: admin@example.org - register: rax_dns -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (rax_argument_spec, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def rax_dns(module, comment, email, name, state, ttl): - changed = False - - dns = pyrax.cloud_dns - if not dns: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if state == 'present': - if not email: - module.fail_json(msg='An "email" attribute is required for ' - 'creating a domain') - - try: - domain = dns.find(name=name) - except pyrax.exceptions.NoUniqueMatch as e: - module.fail_json(msg='%s' % e.message) - except pyrax.exceptions.NotFound: - try: - domain = dns.create(name=name, emailAddress=email, ttl=ttl, - comment=comment) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - update = {} - if comment != getattr(domain, 'comment', None): - update['comment'] = comment - if ttl != getattr(domain, 'ttl', None): - update['ttl'] = ttl - if email != getattr(domain, 'emailAddress', None): - update['emailAddress'] = email - - if update: - try: - domain.update(**update) - changed = True - domain.get() - except Exception as e: - module.fail_json(msg='%s' % e.message) - - elif state == 'absent': - try: - domain = dns.find(name=name) - except pyrax.exceptions.NotFound: - domain = {} - except Exception as e: - module.fail_json(msg='%s' % e.message) - - if domain: - try: - domain.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, domain=rax_to_dict(domain)) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - comment=dict(), - email=dict(), - name=dict(), - state=dict(default='present', choices=['present', 'absent']), - ttl=dict(type='int', default=3600), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - comment = module.params.get('comment') - email = module.params.get('email') - name = module.params.get('name') - state = module.params.get('state') - ttl = module.params.get('ttl') - - setup_rax_module(module, pyrax, False) - - rax_dns(module, comment, email, name, state, ttl) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_dns_record.py b/plugins/modules/rax_dns_record.py deleted file mode 100644 index cb3cd279ef..0000000000 --- a/plugins/modules/rax_dns_record.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_dns_record -short_description: Manage DNS records on Rackspace Cloud DNS -description: - - Manage DNS records on Rackspace Cloud DNS -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - comment: - type: str - description: - - Brief description of the domain. Maximum length of 160 characters - data: - type: str - description: - - IP address for A/AAAA record, FQDN for CNAME/MX/NS, or text data for - SRV/TXT - required: true - domain: - type: str - description: - - Domain name to create the record in. This is an invalid option when - type=PTR - loadbalancer: - type: str - description: - - Load Balancer ID to create a PTR record for. Only used with type=PTR - name: - type: str - description: - - FQDN record name to create - required: true - overwrite: - description: - - Add new records if data doesn't match, instead of updating existing - record with matching name. If there are already multiple records with - matching name and overwrite=true, this module will fail. - default: true - type: bool - priority: - type: int - description: - - Required for MX and SRV records, but forbidden for other record types. - If specified, must be an integer from 0 to 65535. - server: - type: str - description: - - Server ID to create a PTR record for. Only used with type=PTR - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - ttl: - type: int - description: - - Time to live of record in seconds - default: 3600 - type: - type: str - description: - - DNS record type - choices: - - A - - AAAA - - CNAME - - MX - - NS - - SRV - - TXT - - PTR - required: true -notes: - - "It is recommended that plays utilizing this module be run with - C(serial: 1) to avoid exceeding the API request limit imposed by - the Rackspace CloudDNS API." - - To manipulate a C(PTR) record either C(loadbalancer) or C(server) must be - supplied. -author: "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Create DNS Records - hosts: all - gather_facts: false - tasks: - - name: Create A record - local_action: - module: rax_dns_record - credentials: ~/.raxpub - domain: example.org - name: www.example.org - data: "{{ rax_accessipv4 }}" - type: A - register: a_record - - - name: Create PTR record - local_action: - module: rax_dns_record - credentials: ~/.raxpub - server: "{{ rax_id }}" - name: "{{ inventory_hostname }}" - region: DFW - register: ptr_record -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (rax_argument_spec, - rax_find_loadbalancer, - rax_find_server, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def rax_dns_record_ptr(module, data=None, comment=None, loadbalancer=None, - name=None, server=None, state='present', ttl=7200): - changed = False - results = [] - - dns = pyrax.cloud_dns - - if not dns: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if loadbalancer: - item = rax_find_loadbalancer(module, pyrax, loadbalancer) - elif server: - item = rax_find_server(module, pyrax, server) - - if state == 'present': - current = dns.list_ptr_records(item) - for record in current: - if record.data == data: - if record.ttl != ttl or record.name != name: - try: - dns.update_ptr_record(item, record, name, data, ttl) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - record.ttl = ttl - record.name = name - results.append(rax_to_dict(record)) - break - else: - results.append(rax_to_dict(record)) - break - - if not results: - record = dict(name=name, type='PTR', data=data, ttl=ttl, - comment=comment) - try: - results = dns.add_ptr_records(item, [record]) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, records=results) - - elif state == 'absent': - current = dns.list_ptr_records(item) - for record in current: - if record.data == data: - results.append(rax_to_dict(record)) - break - - if results: - try: - dns.delete_ptr_records(item, data) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, records=results) - - -def rax_dns_record(module, comment=None, data=None, domain=None, name=None, - overwrite=True, priority=None, record_type='A', - state='present', ttl=7200): - """Function for manipulating record types other than PTR""" - - changed = False - - dns = pyrax.cloud_dns - if not dns: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if state == 'present': - if not priority and record_type in ['MX', 'SRV']: - module.fail_json(msg='A "priority" attribute is required for ' - 'creating a MX or SRV record') - - try: - domain = dns.find(name=domain) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - try: - if overwrite: - record = domain.find_record(record_type, name=name) - else: - record = domain.find_record(record_type, name=name, data=data) - except pyrax.exceptions.DomainRecordNotUnique as e: - module.fail_json(msg='overwrite=true and there are multiple matching records') - except pyrax.exceptions.DomainRecordNotFound as e: - try: - record_data = { - 'type': record_type, - 'name': name, - 'data': data, - 'ttl': ttl - } - if comment: - record_data.update(dict(comment=comment)) - if priority and record_type.upper() in ['MX', 'SRV']: - record_data.update(dict(priority=priority)) - - record = domain.add_records([record_data])[0] - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - update = {} - if comment != getattr(record, 'comment', None): - update['comment'] = comment - if ttl != getattr(record, 'ttl', None): - update['ttl'] = ttl - if priority != getattr(record, 'priority', None): - update['priority'] = priority - if data != getattr(record, 'data', None): - update['data'] = data - - if update: - try: - record.update(**update) - changed = True - record.get() - except Exception as e: - module.fail_json(msg='%s' % e.message) - - elif state == 'absent': - try: - domain = dns.find(name=domain) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - try: - record = domain.find_record(record_type, name=name, data=data) - except pyrax.exceptions.DomainRecordNotFound as e: - record = {} - except pyrax.exceptions.DomainRecordNotUnique as e: - module.fail_json(msg='%s' % e.message) - - if record: - try: - record.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, record=rax_to_dict(record)) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - comment=dict(), - data=dict(required=True), - domain=dict(), - loadbalancer=dict(), - name=dict(required=True), - overwrite=dict(type='bool', default=True), - priority=dict(type='int'), - server=dict(), - state=dict(default='present', choices=['present', 'absent']), - ttl=dict(type='int', default=3600), - type=dict(required=True, choices=['A', 'AAAA', 'CNAME', 'MX', 'NS', - 'SRV', 'TXT', 'PTR']) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - mutually_exclusive=[ - ['server', 'loadbalancer', 'domain'], - ], - required_one_of=[ - ['server', 'loadbalancer', 'domain'], - ], - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - comment = module.params.get('comment') - data = module.params.get('data') - domain = module.params.get('domain') - loadbalancer = module.params.get('loadbalancer') - name = module.params.get('name') - overwrite = module.params.get('overwrite') - priority = module.params.get('priority') - server = module.params.get('server') - state = module.params.get('state') - ttl = module.params.get('ttl') - record_type = module.params.get('type') - - setup_rax_module(module, pyrax, False) - - if record_type.upper() == 'PTR': - if not server and not loadbalancer: - module.fail_json(msg='one of the following is required: ' - 'server,loadbalancer') - rax_dns_record_ptr(module, data=data, comment=comment, - loadbalancer=loadbalancer, name=name, server=server, - state=state, ttl=ttl) - else: - rax_dns_record(module, comment=comment, data=data, domain=domain, - name=name, overwrite=overwrite, priority=priority, - record_type=record_type, state=state, ttl=ttl) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_facts.py b/plugins/modules/rax_facts.py deleted file mode 100644 index f8bb0e0506..0000000000 --- a/plugins/modules/rax_facts.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_facts -short_description: Gather facts for Rackspace Cloud Servers -description: - - Gather facts for Rackspace Cloud Servers. -attributes: - check_mode: - version_added: 3.3.0 - # This was backported to 2.5.4 and 1.3.11 as well, since this was a bugfix -options: - address: - type: str - description: - - Server IP address to retrieve facts for, will match any IP assigned to - the server - id: - type: str - description: - - Server ID to retrieve facts for - name: - type: str - description: - - Server name to retrieve facts for -author: "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - - community.general.attributes.facts - - community.general.attributes.facts_module - -''' - -EXAMPLES = ''' -- name: Gather info about servers - hosts: all - gather_facts: false - tasks: - - name: Get facts about servers - local_action: - module: rax_facts - credentials: ~/.raxpub - name: "{{ inventory_hostname }}" - region: DFW - - name: Map some facts - ansible.builtin.set_fact: - ansible_ssh_host: "{{ rax_accessipv4 }}" -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (rax_argument_spec, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def rax_facts(module, address, name, server_id): - changed = False - - cs = pyrax.cloudservers - - if cs is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - ansible_facts = {} - - search_opts = {} - if name: - search_opts = dict(name='^%s$' % name) - try: - servers = cs.servers.list(search_opts=search_opts) - except Exception as e: - module.fail_json(msg='%s' % e.message) - elif address: - servers = [] - try: - for server in cs.servers.list(): - for addresses in server.networks.values(): - if address in addresses: - servers.append(server) - break - except Exception as e: - module.fail_json(msg='%s' % e.message) - elif server_id: - servers = [] - try: - servers.append(cs.servers.get(server_id)) - except Exception as e: - pass - - servers[:] = [server for server in servers if server.status != "DELETED"] - - if len(servers) > 1: - module.fail_json(msg='Multiple servers found matching provided ' - 'search parameters') - elif len(servers) == 1: - ansible_facts = rax_to_dict(servers[0], 'server') - - module.exit_json(changed=changed, ansible_facts=ansible_facts) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - address=dict(), - id=dict(), - name=dict(), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - mutually_exclusive=[['address', 'id', 'name']], - required_one_of=[['address', 'id', 'name']], - supports_check_mode=True, - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - address = module.params.get('address') - server_id = module.params.get('id') - name = module.params.get('name') - - setup_rax_module(module, pyrax) - - rax_facts(module, address, name, server_id) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_files.py b/plugins/modules/rax_files.py deleted file mode 100644 index a63e107eb4..0000000000 --- a/plugins/modules/rax_files.py +++ /dev/null @@ -1,400 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright (c) 2013, Paul Durivage -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_files -short_description: Manipulate Rackspace Cloud Files Containers -description: - - Manipulate Rackspace Cloud Files Containers -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - clear_meta: - description: - - Optionally clear existing metadata when applying metadata to existing containers. - Selecting this option is only appropriate when setting type=meta - type: bool - default: false - container: - type: str - description: - - The container to use for container or metadata operations. - meta: - type: dict - default: {} - description: - - A hash of items to set as metadata values on a container - private: - description: - - Used to set a container as private, removing it from the CDN. B(Warning!) - Private containers, if previously made public, can have live objects - available until the TTL on cached objects expires - type: bool - default: false - public: - description: - - Used to set a container as public, available via the Cloud Files CDN - type: bool - default: false - region: - type: str - description: - - Region to create an instance in - state: - type: str - description: - - Indicate desired state of the resource - choices: ['present', 'absent', 'list'] - default: present - ttl: - type: int - description: - - In seconds, set a container-wide TTL for all objects cached on CDN edge nodes. - Setting a TTL is only appropriate for containers that are public - type: - type: str - description: - - Type of object to do work on, i.e. metadata object or a container object - choices: - - container - - meta - default: container - web_error: - type: str - description: - - Sets an object to be presented as the HTTP error page when accessed by the CDN URL - web_index: - type: str - description: - - Sets an object to be presented as the HTTP index page when accessed by the CDN URL -author: "Paul Durivage (@angstwad)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: "Test Cloud Files Containers" - hosts: local - gather_facts: false - tasks: - - name: "List all containers" - community.general.rax_files: - state: list - - - name: "Create container called 'mycontainer'" - community.general.rax_files: - container: mycontainer - - - name: "Create container 'mycontainer2' with metadata" - community.general.rax_files: - container: mycontainer2 - meta: - key: value - file_for: someuser@example.com - - - name: "Set a container's web index page" - community.general.rax_files: - container: mycontainer - web_index: index.html - - - name: "Set a container's web error page" - community.general.rax_files: - container: mycontainer - web_error: error.html - - - name: "Make container public" - community.general.rax_files: - container: mycontainer - public: true - - - name: "Make container public with a 24 hour TTL" - community.general.rax_files: - container: mycontainer - public: true - ttl: 86400 - - - name: "Make container private" - community.general.rax_files: - container: mycontainer - private: true - -- name: "Test Cloud Files Containers Metadata Storage" - hosts: local - gather_facts: false - tasks: - - name: "Get mycontainer2 metadata" - community.general.rax_files: - container: mycontainer2 - type: meta - - - name: "Set mycontainer2 metadata" - community.general.rax_files: - container: mycontainer2 - type: meta - meta: - uploaded_by: someuser@example.com - - - name: "Remove mycontainer2 metadata" - community.general.rax_files: - container: "mycontainer2" - type: meta - state: absent - meta: - key: "" - file_for: "" -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError as e: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -EXIT_DICT = dict(success=True) -META_PREFIX = 'x-container-meta-' - - -def _get_container(module, cf, container): - try: - return cf.get_container(container) - except pyrax.exc.NoSuchContainer as e: - module.fail_json(msg=e.message) - - -def _fetch_meta(module, container): - EXIT_DICT['meta'] = dict() - try: - for k, v in container.get_metadata().items(): - split_key = k.split(META_PREFIX)[-1] - EXIT_DICT['meta'][split_key] = v - except Exception as e: - module.fail_json(msg=e.message) - - -def meta(cf, module, container_, state, meta_, clear_meta): - c = _get_container(module, cf, container_) - - if meta_ and state == 'present': - try: - meta_set = c.set_metadata(meta_, clear=clear_meta) - except Exception as e: - module.fail_json(msg=e.message) - elif meta_ and state == 'absent': - remove_results = [] - for k, v in meta_.items(): - c.remove_metadata_key(k) - remove_results.append(k) - EXIT_DICT['deleted_meta_keys'] = remove_results - elif state == 'absent': - remove_results = [] - for k, v in c.get_metadata().items(): - c.remove_metadata_key(k) - remove_results.append(k) - EXIT_DICT['deleted_meta_keys'] = remove_results - - _fetch_meta(module, c) - _locals = locals().keys() - - EXIT_DICT['container'] = c.name - if 'meta_set' in _locals or 'remove_results' in _locals: - EXIT_DICT['changed'] = True - - module.exit_json(**EXIT_DICT) - - -def container(cf, module, container_, state, meta_, clear_meta, ttl, public, - private, web_index, web_error): - if public and private: - module.fail_json(msg='container cannot be simultaneously ' - 'set to public and private') - - if state == 'absent' and (meta_ or clear_meta or public or private or web_index or web_error): - module.fail_json(msg='state cannot be omitted when setting/removing ' - 'attributes on a container') - - if state == 'list': - # We don't care if attributes are specified, let's list containers - EXIT_DICT['containers'] = cf.list_containers() - module.exit_json(**EXIT_DICT) - - try: - c = cf.get_container(container_) - except pyrax.exc.NoSuchContainer as e: - # Make the container if state=present, otherwise bomb out - if state == 'present': - try: - c = cf.create_container(container_) - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['changed'] = True - EXIT_DICT['created'] = True - else: - module.fail_json(msg=e.message) - else: - # Successfully grabbed a container object - # Delete if state is absent - if state == 'absent': - try: - cont_deleted = c.delete() - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['deleted'] = True - - if meta_: - try: - meta_set = c.set_metadata(meta_, clear=clear_meta) - except Exception as e: - module.fail_json(msg=e.message) - finally: - _fetch_meta(module, c) - - if ttl: - try: - c.cdn_ttl = ttl - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['ttl'] = c.cdn_ttl - - if public: - try: - cont_public = c.make_public() - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['container_urls'] = dict(url=c.cdn_uri, - ssl_url=c.cdn_ssl_uri, - streaming_url=c.cdn_streaming_uri, - ios_uri=c.cdn_ios_uri) - - if private: - try: - cont_private = c.make_private() - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['set_private'] = True - - if web_index: - try: - cont_web_index = c.set_web_index_page(web_index) - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['set_index'] = True - finally: - _fetch_meta(module, c) - - if web_error: - try: - cont_err_index = c.set_web_error_page(web_error) - except Exception as e: - module.fail_json(msg=e.message) - else: - EXIT_DICT['set_error'] = True - finally: - _fetch_meta(module, c) - - EXIT_DICT['container'] = c.name - EXIT_DICT['objs_in_container'] = c.object_count - EXIT_DICT['total_bytes'] = c.total_bytes - - _locals = locals().keys() - if ('cont_deleted' in _locals - or 'meta_set' in _locals - or 'cont_public' in _locals - or 'cont_private' in _locals - or 'cont_web_index' in _locals - or 'cont_err_index' in _locals): - EXIT_DICT['changed'] = True - - module.exit_json(**EXIT_DICT) - - -def cloudfiles(module, container_, state, meta_, clear_meta, typ, ttl, public, - private, web_index, web_error): - """ Dispatch from here to work with metadata or file objects """ - cf = pyrax.cloudfiles - - if cf is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if typ == "container": - container(cf, module, container_, state, meta_, clear_meta, ttl, - public, private, web_index, web_error) - else: - meta(cf, module, container_, state, meta_, clear_meta) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - container=dict(), - state=dict(choices=['present', 'absent', 'list'], - default='present'), - meta=dict(type='dict', default=dict()), - clear_meta=dict(default=False, type='bool'), - type=dict(choices=['container', 'meta'], default='container'), - ttl=dict(type='int'), - public=dict(default=False, type='bool'), - private=dict(default=False, type='bool'), - web_index=dict(), - web_error=dict() - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - container_ = module.params.get('container') - state = module.params.get('state') - meta_ = module.params.get('meta') - clear_meta = module.params.get('clear_meta') - typ = module.params.get('type') - ttl = module.params.get('ttl') - public = module.params.get('public') - private = module.params.get('private') - web_index = module.params.get('web_index') - web_error = module.params.get('web_error') - - if state in ['present', 'absent'] and not container_: - module.fail_json(msg='please specify a container name') - if clear_meta and not typ == 'meta': - module.fail_json(msg='clear_meta can only be used when setting ' - 'metadata') - - setup_rax_module(module, pyrax) - cloudfiles(module, container_, state, meta_, clear_meta, typ, ttl, public, - private, web_index, web_error) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_files_objects.py b/plugins/modules/rax_files_objects.py deleted file mode 100644 index bbcdfe4f80..0000000000 --- a/plugins/modules/rax_files_objects.py +++ /dev/null @@ -1,556 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright (c) 2013, Paul Durivage -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_files_objects -short_description: Upload, download, and delete objects in Rackspace Cloud Files -description: - - Upload, download, and delete objects in Rackspace Cloud Files. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - clear_meta: - description: - - Optionally clear existing metadata when applying metadata to existing objects. - Selecting this option is only appropriate when setting O(type=meta). - type: bool - default: false - container: - type: str - description: - - The container to use for file object operations. - required: true - dest: - type: str - description: - - The destination of a C(get) operation; i.e. a local directory, C(/home/user/myfolder). - Used to specify the destination of an operation on a remote object; i.e. a file name, - V(file1), or a comma-separated list of remote objects, V(file1,file2,file17). - expires: - type: int - description: - - Used to set an expiration in seconds on an uploaded file or folder. - meta: - type: dict - default: {} - description: - - Items to set as metadata values on an uploaded file or folder. - method: - type: str - description: - - > - The method of operation to be performed: V(put) to upload files, V(get) to download files or - V(delete) to remove remote objects in Cloud Files. - choices: - - get - - put - - delete - default: get - src: - type: str - description: - - Source from which to upload files. Used to specify a remote object as a source for - an operation, i.e. a file name, V(file1), or a comma-separated list of remote objects, - V(file1,file2,file17). Parameters O(src) and O(dest) are mutually exclusive on remote-only object operations - structure: - description: - - Used to specify whether to maintain nested directory structure when downloading objects - from Cloud Files. Setting to false downloads the contents of a container to a single, - flat directory - type: bool - default: true - type: - type: str - description: - - Type of object to do work on - - Metadata object or a file object - choices: - - file - - meta - default: file -author: "Paul Durivage (@angstwad)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: "Test Cloud Files Objects" - hosts: local - gather_facts: false - tasks: - - name: "Get objects from test container" - community.general.rax_files_objects: - container: testcont - dest: ~/Downloads/testcont - - - name: "Get single object from test container" - community.general.rax_files_objects: - container: testcont - src: file1 - dest: ~/Downloads/testcont - - - name: "Get several objects from test container" - community.general.rax_files_objects: - container: testcont - src: file1,file2,file3 - dest: ~/Downloads/testcont - - - name: "Delete one object in test container" - community.general.rax_files_objects: - container: testcont - method: delete - dest: file1 - - - name: "Delete several objects in test container" - community.general.rax_files_objects: - container: testcont - method: delete - dest: file2,file3,file4 - - - name: "Delete all objects in test container" - community.general.rax_files_objects: - container: testcont - method: delete - - - name: "Upload all files to test container" - community.general.rax_files_objects: - container: testcont - method: put - src: ~/Downloads/onehundred - - - name: "Upload one file to test container" - community.general.rax_files_objects: - container: testcont - method: put - src: ~/Downloads/testcont/file1 - - - name: "Upload one file to test container with metadata" - community.general.rax_files_objects: - container: testcont - src: ~/Downloads/testcont/file2 - method: put - meta: - testkey: testdata - who_uploaded_this: someuser@example.com - - - name: "Upload one file to test container with TTL of 60 seconds" - community.general.rax_files_objects: - container: testcont - method: put - src: ~/Downloads/testcont/file3 - expires: 60 - - - name: "Attempt to get remote object that does not exist" - community.general.rax_files_objects: - container: testcont - method: get - src: FileThatDoesNotExist.jpg - dest: ~/Downloads/testcont - ignore_errors: true - - - name: "Attempt to delete remote object that does not exist" - community.general.rax_files_objects: - container: testcont - method: delete - dest: FileThatDoesNotExist.jpg - ignore_errors: true - -- name: "Test Cloud Files Objects Metadata" - hosts: local - gather_facts: false - tasks: - - name: "Get metadata on one object" - community.general.rax_files_objects: - container: testcont - type: meta - dest: file2 - - - name: "Get metadata on several objects" - community.general.rax_files_objects: - container: testcont - type: meta - src: file2,file1 - - - name: "Set metadata on an object" - community.general.rax_files_objects: - container: testcont - type: meta - dest: file17 - method: put - meta: - key1: value1 - key2: value2 - clear_meta: true - - - name: "Verify metadata is set" - community.general.rax_files_objects: - container: testcont - type: meta - src: file17 - - - name: "Delete metadata" - community.general.rax_files_objects: - container: testcont - type: meta - dest: file17 - method: delete - meta: - key1: '' - key2: '' - - - name: "Get metadata on all objects" - community.general.rax_files_objects: - container: testcont - type: meta -''' - -import os - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -EXIT_DICT = dict(success=False) -META_PREFIX = 'x-object-meta-' - - -def _get_container(module, cf, container): - try: - return cf.get_container(container) - except pyrax.exc.NoSuchContainer as e: - module.fail_json(msg=e.message) - - -def _upload_folder(cf, folder, container, ttl=None, headers=None): - """ Uploads a folder to Cloud Files. - """ - total_bytes = 0 - for root, dummy, files in os.walk(folder): - for fname in files: - full_path = os.path.join(root, fname) - obj_name = os.path.relpath(full_path, folder) - obj_size = os.path.getsize(full_path) - cf.upload_file(container, full_path, obj_name=obj_name, return_none=True, ttl=ttl, headers=headers) - total_bytes += obj_size - return total_bytes - - -def upload(module, cf, container, src, dest, meta, expires): - """ Uploads a single object or a folder to Cloud Files Optionally sets an - metadata, TTL value (expires), or Content-Disposition and Content-Encoding - headers. - """ - if not src: - module.fail_json(msg='src must be specified when uploading') - - c = _get_container(module, cf, container) - src = os.path.abspath(os.path.expanduser(src)) - is_dir = os.path.isdir(src) - - if not is_dir and not os.path.isfile(src) or not os.path.exists(src): - module.fail_json(msg='src must be a file or a directory') - if dest and is_dir: - module.fail_json(msg='dest cannot be set when whole ' - 'directories are uploaded') - - cont_obj = None - total_bytes = 0 - try: - if dest and not is_dir: - cont_obj = c.upload_file(src, obj_name=dest, ttl=expires, headers=meta) - elif is_dir: - total_bytes = _upload_folder(cf, src, c, ttl=expires, headers=meta) - else: - cont_obj = c.upload_file(src, ttl=expires, headers=meta) - except Exception as e: - module.fail_json(msg=e.message) - - EXIT_DICT['success'] = True - EXIT_DICT['container'] = c.name - EXIT_DICT['msg'] = "Uploaded %s to container: %s" % (src, c.name) - if cont_obj or total_bytes > 0: - EXIT_DICT['changed'] = True - if meta: - EXIT_DICT['meta'] = dict(updated=True) - - if cont_obj: - EXIT_DICT['bytes'] = cont_obj.total_bytes - EXIT_DICT['etag'] = cont_obj.etag - else: - EXIT_DICT['bytes'] = total_bytes - - module.exit_json(**EXIT_DICT) - - -def download(module, cf, container, src, dest, structure): - """ Download objects from Cloud Files to a local path specified by "dest". - Optionally disable maintaining a directory structure by by passing a - false value to "structure". - """ - # Looking for an explicit destination - if not dest: - module.fail_json(msg='dest is a required argument when ' - 'downloading from Cloud Files') - - # Attempt to fetch the container by name - c = _get_container(module, cf, container) - - # Accept a single object name or a comma-separated list of objs - # If not specified, get the entire container - if src: - objs = map(str.strip, src.split(',')) - else: - objs = c.get_object_names() - - dest = os.path.abspath(os.path.expanduser(dest)) - is_dir = os.path.isdir(dest) - - if not is_dir: - module.fail_json(msg='dest must be a directory') - - try: - results = [c.download_object(obj, dest, structure=structure) for obj in objs] - except Exception as e: - module.fail_json(msg=e.message) - - len_results = len(results) - len_objs = len(objs) - - EXIT_DICT['container'] = c.name - EXIT_DICT['requested_downloaded'] = results - if results: - EXIT_DICT['changed'] = True - if len_results == len_objs: - EXIT_DICT['success'] = True - EXIT_DICT['msg'] = "%s objects downloaded to %s" % (len_results, dest) - else: - EXIT_DICT['msg'] = "Error: only %s of %s objects were " \ - "downloaded" % (len_results, len_objs) - module.exit_json(**EXIT_DICT) - - -def delete(module, cf, container, src, dest): - """ Delete specific objects by proving a single file name or a - comma-separated list to src OR dest (but not both). Omitting file name(s) - assumes the entire container is to be deleted. - """ - if src and dest: - module.fail_json(msg="Error: ambiguous instructions; files to be deleted " - "have been specified on both src and dest args") - - c = _get_container(module, cf, container) - - objs = dest or src - if objs: - objs = map(str.strip, objs.split(',')) - else: - objs = c.get_object_names() - - num_objs = len(objs) - - try: - results = [c.delete_object(obj) for obj in objs] - except Exception as e: - module.fail_json(msg=e.message) - - num_deleted = results.count(True) - - EXIT_DICT['container'] = c.name - EXIT_DICT['deleted'] = num_deleted - EXIT_DICT['requested_deleted'] = objs - - if num_deleted: - EXIT_DICT['changed'] = True - - if num_objs == num_deleted: - EXIT_DICT['success'] = True - EXIT_DICT['msg'] = "%s objects deleted" % num_deleted - else: - EXIT_DICT['msg'] = ("Error: only %s of %s objects " - "deleted" % (num_deleted, num_objs)) - module.exit_json(**EXIT_DICT) - - -def get_meta(module, cf, container, src, dest): - """ Get metadata for a single file, comma-separated list, or entire - container - """ - if src and dest: - module.fail_json(msg="Error: ambiguous instructions; files to be deleted " - "have been specified on both src and dest args") - - c = _get_container(module, cf, container) - - objs = dest or src - if objs: - objs = map(str.strip, objs.split(',')) - else: - objs = c.get_object_names() - - try: - results = dict() - for obj in objs: - meta = c.get_object(obj).get_metadata() - results[obj] = dict((k.split(META_PREFIX)[-1], v) for k, v in meta.items()) - except Exception as e: - module.fail_json(msg=e.message) - - EXIT_DICT['container'] = c.name - if results: - EXIT_DICT['meta_results'] = results - EXIT_DICT['success'] = True - module.exit_json(**EXIT_DICT) - - -def put_meta(module, cf, container, src, dest, meta, clear_meta): - """ Set metadata on a container, single file, or comma-separated list. - Passing a true value to clear_meta clears the metadata stored in Cloud - Files before setting the new metadata to the value of "meta". - """ - if src and dest: - module.fail_json(msg="Error: ambiguous instructions; files to set meta" - " have been specified on both src and dest args") - objs = dest or src - objs = map(str.strip, objs.split(',')) - - c = _get_container(module, cf, container) - - try: - results = [c.get_object(obj).set_metadata(meta, clear=clear_meta) for obj in objs] - except Exception as e: - module.fail_json(msg=e.message) - - EXIT_DICT['container'] = c.name - EXIT_DICT['success'] = True - if results: - EXIT_DICT['changed'] = True - EXIT_DICT['num_changed'] = True - module.exit_json(**EXIT_DICT) - - -def delete_meta(module, cf, container, src, dest, meta): - """ Removes metadata keys and values specified in meta, if any. Deletes on - all objects specified by src or dest (but not both), if any; otherwise it - deletes keys on all objects in the container - """ - if src and dest: - module.fail_json(msg="Error: ambiguous instructions; meta keys to be " - "deleted have been specified on both src and dest" - " args") - objs = dest or src - objs = map(str.strip, objs.split(',')) - - c = _get_container(module, cf, container) - - try: - for obj in objs: - o = c.get_object(obj) - results = [ - o.remove_metadata_key(k) - for k in (meta or o.get_metadata()) - ] - except Exception as e: - module.fail_json(msg=e.message) - - EXIT_DICT['container'] = c.name - EXIT_DICT['success'] = True - if results: - EXIT_DICT['changed'] = True - EXIT_DICT['num_deleted'] = len(results) - module.exit_json(**EXIT_DICT) - - -def cloudfiles(module, container, src, dest, method, typ, meta, clear_meta, - structure, expires): - """ Dispatch from here to work with metadata or file objects """ - cf = pyrax.cloudfiles - - if cf is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if typ == "file": - if method == 'get': - download(module, cf, container, src, dest, structure) - - if method == 'put': - upload(module, cf, container, src, dest, meta, expires) - - if method == 'delete': - delete(module, cf, container, src, dest) - - else: - if method == 'get': - get_meta(module, cf, container, src, dest) - - if method == 'put': - put_meta(module, cf, container, src, dest, meta, clear_meta) - - if method == 'delete': - delete_meta(module, cf, container, src, dest, meta) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - container=dict(required=True), - src=dict(), - dest=dict(), - method=dict(default='get', choices=['put', 'get', 'delete']), - type=dict(default='file', choices=['file', 'meta']), - meta=dict(type='dict', default=dict()), - clear_meta=dict(default=False, type='bool'), - structure=dict(default=True, type='bool'), - expires=dict(type='int'), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - container = module.params.get('container') - src = module.params.get('src') - dest = module.params.get('dest') - method = module.params.get('method') - typ = module.params.get('type') - meta = module.params.get('meta') - clear_meta = module.params.get('clear_meta') - structure = module.params.get('structure') - expires = module.params.get('expires') - - if clear_meta and not typ == 'meta': - module.fail_json(msg='clear_meta can only be used when setting metadata') - - setup_rax_module(module, pyrax) - cloudfiles(module, container, src, dest, method, typ, meta, clear_meta, structure, expires) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_identity.py b/plugins/modules/rax_identity.py deleted file mode 100644 index b2eb156273..0000000000 --- a/plugins/modules/rax_identity.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_identity -short_description: Load Rackspace Cloud Identity -description: - - Verifies Rackspace Cloud credentials and returns identity information -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - state: - type: str - description: - - Indicate desired state of the resource - choices: ['present'] - default: present - required: false -author: - - "Christopher H. Laco (@claco)" - - "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Load Rackspace Cloud Identity - gather_facts: false - hosts: local - connection: local - tasks: - - name: Load Identity - local_action: - module: rax_identity - credentials: ~/.raxpub - region: DFW - register: rackspace_identity -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (rax_argument_spec, rax_required_together, rax_to_dict, - setup_rax_module) - - -def cloud_identity(module, state, identity): - instance = dict( - authenticated=identity.authenticated, - credentials=identity._creds_file - ) - changed = False - - instance.update(rax_to_dict(identity)) - instance['services'] = instance.get('services', {}).keys() - - if state == 'present': - if not identity.authenticated: - module.fail_json(msg='Credentials could not be verified!') - - module.exit_json(changed=changed, identity=instance) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - state=dict(default='present', choices=['present']) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - state = module.params.get('state') - - setup_rax_module(module, pyrax) - - if not pyrax.identity: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - cloud_identity(module, state, pyrax.identity) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_keypair.py b/plugins/modules/rax_keypair.py deleted file mode 100644 index d7d7a2cc34..0000000000 --- a/plugins/modules/rax_keypair.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_keypair -short_description: Create a keypair for use with Rackspace Cloud Servers -description: - - Create a keypair for use with Rackspace Cloud Servers -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - name: - type: str - description: - - Name of keypair - required: true - public_key: - type: str - description: - - Public Key string to upload. Can be a file path or string - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present -author: "Matt Martz (@sivel)" -notes: - - Keypairs cannot be manipulated, only created and deleted. To "update" a - keypair you must first delete and then recreate. - - The ability to specify a file path for the public key was added in 1.7 -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Create a keypair - hosts: localhost - gather_facts: false - tasks: - - name: Keypair request - local_action: - module: rax_keypair - credentials: ~/.raxpub - name: my_keypair - region: DFW - register: keypair - - name: Create local public key - local_action: - module: copy - content: "{{ keypair.keypair.public_key }}" - dest: "{{ inventory_dir }}/{{ keypair.keypair.name }}.pub" - - name: Create local private key - local_action: - module: copy - content: "{{ keypair.keypair.private_key }}" - dest: "{{ inventory_dir }}/{{ keypair.keypair.name }}" - -- name: Create a keypair - hosts: localhost - gather_facts: false - tasks: - - name: Keypair request - local_action: - module: rax_keypair - credentials: ~/.raxpub - name: my_keypair - public_key: "{{ lookup('file', 'authorized_keys/id_rsa.pub') }}" - region: DFW - register: keypair -''' -import os - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (rax_argument_spec, - rax_required_together, - rax_to_dict, - setup_rax_module, - ) - - -def rax_keypair(module, name, public_key, state): - changed = False - - cs = pyrax.cloudservers - - if cs is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - keypair = {} - - if state == 'present': - if public_key and os.path.isfile(public_key): - try: - f = open(public_key) - public_key = f.read() - f.close() - except Exception as e: - module.fail_json(msg='Failed to load %s' % public_key) - - try: - keypair = cs.keypairs.find(name=name) - except cs.exceptions.NotFound: - try: - keypair = cs.keypairs.create(name, public_key) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - elif state == 'absent': - try: - keypair = cs.keypairs.find(name=name) - except Exception: - pass - - if keypair: - try: - keypair.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, keypair=rax_to_dict(keypair)) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - name=dict(required=True), - public_key=dict(), - state=dict(default='present', choices=['absent', 'present']), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - name = module.params.get('name') - public_key = module.params.get('public_key') - state = module.params.get('state') - - setup_rax_module(module, pyrax) - - rax_keypair(module, name, public_key, state) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_meta.py b/plugins/modules/rax_meta.py deleted file mode 100644 index 7b52e906fe..0000000000 --- a/plugins/modules/rax_meta.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_meta -short_description: Manipulate metadata for Rackspace Cloud Servers -description: - - Manipulate metadata for Rackspace Cloud Servers -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - address: - type: str - description: - - Server IP address to modify metadata for, will match any IP assigned to - the server - id: - type: str - description: - - Server ID to modify metadata for - name: - type: str - description: - - Server name to modify metadata for - meta: - type: dict - default: {} - description: - - A hash of metadata to associate with the instance -author: "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Set metadata for a server - hosts: all - gather_facts: false - tasks: - - name: Set metadata - local_action: - module: rax_meta - credentials: ~/.raxpub - name: "{{ inventory_hostname }}" - region: DFW - meta: - group: primary_group - groups: - - group_two - - group_three - app: my_app - - - name: Clear metadata - local_action: - module: rax_meta - credentials: ~/.raxpub - name: "{{ inventory_hostname }}" - region: DFW -''' - -import json - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module -from ansible.module_utils.six import string_types - - -def rax_meta(module, address, name, server_id, meta): - changed = False - - cs = pyrax.cloudservers - - if cs is None: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - search_opts = {} - if name: - search_opts = dict(name='^%s$' % name) - try: - servers = cs.servers.list(search_opts=search_opts) - except Exception as e: - module.fail_json(msg='%s' % e.message) - elif address: - servers = [] - try: - for server in cs.servers.list(): - for addresses in server.networks.values(): - if address in addresses: - servers.append(server) - break - except Exception as e: - module.fail_json(msg='%s' % e.message) - elif server_id: - servers = [] - try: - servers.append(cs.servers.get(server_id)) - except Exception as e: - pass - - if len(servers) > 1: - module.fail_json(msg='Multiple servers found matching provided ' - 'search parameters') - elif not servers: - module.fail_json(msg='Failed to find a server matching provided ' - 'search parameters') - - # Normalize and ensure all metadata values are strings - for k, v in meta.items(): - if isinstance(v, list): - meta[k] = ','.join(['%s' % i for i in v]) - elif isinstance(v, dict): - meta[k] = json.dumps(v) - elif not isinstance(v, string_types): - meta[k] = '%s' % v - - server = servers[0] - if server.metadata == meta: - changed = False - else: - changed = True - removed = set(server.metadata.keys()).difference(meta.keys()) - cs.servers.delete_meta(server, list(removed)) - cs.servers.set_meta(server, meta) - server.get() - - module.exit_json(changed=changed, meta=server.metadata) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - address=dict(), - id=dict(), - name=dict(), - meta=dict(type='dict', default=dict()), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - mutually_exclusive=[['address', 'id', 'name']], - required_one_of=[['address', 'id', 'name']], - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - address = module.params.get('address') - server_id = module.params.get('id') - name = module.params.get('name') - meta = module.params.get('meta') - - setup_rax_module(module, pyrax) - - rax_meta(module, address, name, server_id, meta) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_mon_alarm.py b/plugins/modules/rax_mon_alarm.py deleted file mode 100644 index b66611a90f..0000000000 --- a/plugins/modules/rax_mon_alarm.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_mon_alarm -short_description: Create or delete a Rackspace Cloud Monitoring alarm -description: -- Create or delete a Rackspace Cloud Monitoring alarm that associates an - existing rax_mon_entity, rax_mon_check, and rax_mon_notification_plan with - criteria that specify what conditions will trigger which levels of - notifications. Rackspace monitoring module flow | rax_mon_entity -> - rax_mon_check -> rax_mon_notification -> rax_mon_notification_plan -> - *rax_mon_alarm* -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - state: - type: str - description: - - Ensure that the alarm with this O(label) exists or does not exist. - choices: [ "present", "absent" ] - required: false - default: present - label: - type: str - description: - - Friendly name for this alarm, used to achieve idempotence. Must be a String - between 1 and 255 characters long. - required: true - entity_id: - type: str - description: - - ID of the entity this alarm is attached to. May be acquired by registering - the value of a rax_mon_entity task. - required: true - check_id: - type: str - description: - - ID of the check that should be alerted on. May be acquired by registering - the value of a rax_mon_check task. - required: true - notification_plan_id: - type: str - description: - - ID of the notification plan to trigger if this alarm fires. May be acquired - by registering the value of a rax_mon_notification_plan task. - required: true - criteria: - type: str - description: - - Alarm DSL that describes alerting conditions and their output states. Must - be between 1 and 16384 characters long. See - http://docs.rackspace.com/cm/api/v1.0/cm-devguide/content/alerts-language.html - for a reference on the alerting language. - disabled: - description: - - If yes, create this alarm, but leave it in an inactive state. Defaults to - no. - type: bool - default: false - metadata: - type: dict - description: - - Arbitrary key/value pairs to accompany the alarm. Must be a hash of String - keys and values between 1 and 255 characters long. -author: Ash Wilson (@smashwilson) -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Alarm example - gather_facts: false - hosts: local - connection: local - tasks: - - name: Ensure that a specific alarm exists. - community.general.rax_mon_alarm: - credentials: ~/.rax_pub - state: present - label: uhoh - entity_id: "{{ the_entity['entity']['id'] }}" - check_id: "{{ the_check['check']['id'] }}" - notification_plan_id: "{{ defcon1['notification_plan']['id'] }}" - criteria: > - if (rate(metric['average']) > 10) { - return new AlarmStatus(WARNING); - } - return new AlarmStatus(OK); - register: the_alarm -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def alarm(module, state, label, entity_id, check_id, notification_plan_id, criteria, - disabled, metadata): - - if len(label) < 1 or len(label) > 255: - module.fail_json(msg='label must be between 1 and 255 characters long') - - if criteria and len(criteria) < 1 or len(criteria) > 16384: - module.fail_json(msg='criteria must be between 1 and 16384 characters long') - - # Coerce attributes. - - changed = False - alarm = None - - cm = pyrax.cloud_monitoring - if not cm: - module.fail_json(msg='Failed to instantiate client. This typically ' - 'indicates an invalid region or an incorrectly ' - 'capitalized region name.') - - existing = [a for a in cm.list_alarms(entity_id) if a.label == label] - - if existing: - alarm = existing[0] - - if state == 'present': - should_create = False - should_update = False - should_delete = False - - if len(existing) > 1: - module.fail_json(msg='%s existing alarms have the label %s.' % - (len(existing), label)) - - if alarm: - if check_id != alarm.check_id or notification_plan_id != alarm.notification_plan_id: - should_delete = should_create = True - - should_update = (disabled and disabled != alarm.disabled) or \ - (metadata and metadata != alarm.metadata) or \ - (criteria and criteria != alarm.criteria) - - if should_update and not should_delete: - cm.update_alarm(entity=entity_id, alarm=alarm, - criteria=criteria, disabled=disabled, - label=label, metadata=metadata) - changed = True - - if should_delete: - alarm.delete() - changed = True - else: - should_create = True - - if should_create: - alarm = cm.create_alarm(entity=entity_id, check=check_id, - notification_plan=notification_plan_id, - criteria=criteria, disabled=disabled, label=label, - metadata=metadata) - changed = True - else: - for a in existing: - a.delete() - changed = True - - if alarm: - alarm_dict = { - "id": alarm.id, - "label": alarm.label, - "check_id": alarm.check_id, - "notification_plan_id": alarm.notification_plan_id, - "criteria": alarm.criteria, - "disabled": alarm.disabled, - "metadata": alarm.metadata - } - module.exit_json(changed=changed, alarm=alarm_dict) - else: - module.exit_json(changed=changed) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - state=dict(default='present', choices=['present', 'absent']), - label=dict(required=True), - entity_id=dict(required=True), - check_id=dict(required=True), - notification_plan_id=dict(required=True), - criteria=dict(), - disabled=dict(type='bool', default=False), - metadata=dict(type='dict') - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - state = module.params.get('state') - label = module.params.get('label') - entity_id = module.params.get('entity_id') - check_id = module.params.get('check_id') - notification_plan_id = module.params.get('notification_plan_id') - criteria = module.params.get('criteria') - disabled = module.boolean(module.params.get('disabled')) - metadata = module.params.get('metadata') - - setup_rax_module(module, pyrax) - - alarm(module, state, label, entity_id, check_id, notification_plan_id, - criteria, disabled, metadata) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_mon_check.py b/plugins/modules/rax_mon_check.py deleted file mode 100644 index 253c26dcf5..0000000000 --- a/plugins/modules/rax_mon_check.py +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_mon_check -short_description: Create or delete a Rackspace Cloud Monitoring check for an - existing entity. -description: -- Create or delete a Rackspace Cloud Monitoring check associated with an - existing rax_mon_entity. A check is a specific test or measurement that is - performed, possibly from different monitoring zones, on the systems you - monitor. Rackspace monitoring module flow | rax_mon_entity -> - *rax_mon_check* -> rax_mon_notification -> rax_mon_notification_plan -> - rax_mon_alarm -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - state: - type: str - description: - - Ensure that a check with this O(label) exists or does not exist. - choices: ["present", "absent"] - default: present - entity_id: - type: str - description: - - ID of the rax_mon_entity to target with this check. - required: true - label: - type: str - description: - - Defines a label for this check, between 1 and 64 characters long. - required: true - check_type: - type: str - description: - - The type of check to create. C(remote.) checks may be created on any - rax_mon_entity. C(agent.) checks may only be created on rax_mon_entities - that have a non-null C(agent_id). - - | - Choices for this option are: - - V(remote.dns) - - V(remote.ftp-banner) - - V(remote.http) - - V(remote.imap-banner) - - V(remote.mssql-banner) - - V(remote.mysql-banner) - - V(remote.ping) - - V(remote.pop3-banner) - - V(remote.postgresql-banner) - - V(remote.smtp-banner) - - V(remote.smtp) - - V(remote.ssh) - - V(remote.tcp) - - V(remote.telnet-banner) - - V(agent.filesystem) - - V(agent.memory) - - V(agent.load_average) - - V(agent.cpu) - - V(agent.disk) - - V(agent.network) - - V(agent.plugin) - required: true - monitoring_zones_poll: - type: str - description: - - Comma-separated list of the names of the monitoring zones the check should - run from. Available monitoring zones include mzdfw, mzhkg, mziad, mzlon, - mzord and mzsyd. Required for remote.* checks; prohibited for agent.* checks. - target_hostname: - type: str - description: - - One of O(target_hostname) and O(target_alias) is required for remote.* checks, - but prohibited for agent.* checks. The hostname this check should target. - Must be a valid IPv4, IPv6, or FQDN. - target_alias: - type: str - description: - - One of O(target_alias) and O(target_hostname) is required for remote.* checks, - but prohibited for agent.* checks. Use the corresponding key in the entity's - C(ip_addresses) hash to resolve an IP address to target. - details: - type: dict - default: {} - description: - - Additional details specific to the check type. Must be a hash of strings - between 1 and 255 characters long, or an array or object containing 0 to - 256 items. - disabled: - description: - - If V(true), ensure the check is created, but don't actually use it yet. - type: bool - default: false - metadata: - type: dict - default: {} - description: - - Hash of arbitrary key-value pairs to accompany this check if it fires. - Keys and values must be strings between 1 and 255 characters long. - period: - type: int - description: - - The number of seconds between each time the check is performed. Must be - greater than the minimum period set on your account. - timeout: - type: int - description: - - The number of seconds this check will wait when attempting to collect - results. Must be less than the period. -author: Ash Wilson (@smashwilson) -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Create a monitoring check - gather_facts: false - hosts: local - connection: local - tasks: - - name: Associate a check with an existing entity. - community.general.rax_mon_check: - credentials: ~/.rax_pub - state: present - entity_id: "{{ the_entity['entity']['id'] }}" - label: the_check - check_type: remote.ping - monitoring_zones_poll: mziad,mzord,mzdfw - details: - count: 10 - meta: - hurf: durf - register: the_check -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def cloud_check(module, state, entity_id, label, check_type, - monitoring_zones_poll, target_hostname, target_alias, details, - disabled, metadata, period, timeout): - - # Coerce attributes. - - if monitoring_zones_poll and not isinstance(monitoring_zones_poll, list): - monitoring_zones_poll = [monitoring_zones_poll] - - if period: - period = int(period) - - if timeout: - timeout = int(timeout) - - changed = False - check = None - - cm = pyrax.cloud_monitoring - if not cm: - module.fail_json(msg='Failed to instantiate client. This typically ' - 'indicates an invalid region or an incorrectly ' - 'capitalized region name.') - - entity = cm.get_entity(entity_id) - if not entity: - module.fail_json(msg='Failed to instantiate entity. "%s" may not be' - ' a valid entity id.' % entity_id) - - existing = [e for e in entity.list_checks() if e.label == label] - - if existing: - check = existing[0] - - if state == 'present': - if len(existing) > 1: - module.fail_json(msg='%s existing checks have a label of %s.' % - (len(existing), label)) - - should_delete = False - should_create = False - should_update = False - - if check: - # Details may include keys set to default values that are not - # included in the initial creation. - # - # Only force a recreation of the check if one of the *specified* - # keys is missing or has a different value. - if details: - for (key, value) in details.items(): - if key not in check.details: - should_delete = should_create = True - elif value != check.details[key]: - should_delete = should_create = True - - should_update = label != check.label or \ - (target_hostname and target_hostname != check.target_hostname) or \ - (target_alias and target_alias != check.target_alias) or \ - (disabled != check.disabled) or \ - (metadata and metadata != check.metadata) or \ - (period and period != check.period) or \ - (timeout and timeout != check.timeout) or \ - (monitoring_zones_poll and monitoring_zones_poll != check.monitoring_zones_poll) - - if should_update and not should_delete: - check.update(label=label, - disabled=disabled, - metadata=metadata, - monitoring_zones_poll=monitoring_zones_poll, - timeout=timeout, - period=period, - target_alias=target_alias, - target_hostname=target_hostname) - changed = True - else: - # The check doesn't exist yet. - should_create = True - - if should_delete: - check.delete() - - if should_create: - check = cm.create_check(entity, - label=label, - check_type=check_type, - target_hostname=target_hostname, - target_alias=target_alias, - monitoring_zones_poll=monitoring_zones_poll, - details=details, - disabled=disabled, - metadata=metadata, - period=period, - timeout=timeout) - changed = True - elif state == 'absent': - if check: - check.delete() - changed = True - else: - module.fail_json(msg='state must be either present or absent.') - - if check: - check_dict = { - "id": check.id, - "label": check.label, - "type": check.type, - "target_hostname": check.target_hostname, - "target_alias": check.target_alias, - "monitoring_zones_poll": check.monitoring_zones_poll, - "details": check.details, - "disabled": check.disabled, - "metadata": check.metadata, - "period": check.period, - "timeout": check.timeout - } - module.exit_json(changed=changed, check=check_dict) - else: - module.exit_json(changed=changed) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - entity_id=dict(required=True), - label=dict(required=True), - check_type=dict(required=True), - monitoring_zones_poll=dict(), - target_hostname=dict(), - target_alias=dict(), - details=dict(type='dict', default={}), - disabled=dict(type='bool', default=False), - metadata=dict(type='dict', default={}), - period=dict(type='int'), - timeout=dict(type='int'), - state=dict(default='present', choices=['present', 'absent']) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - entity_id = module.params.get('entity_id') - label = module.params.get('label') - check_type = module.params.get('check_type') - monitoring_zones_poll = module.params.get('monitoring_zones_poll') - target_hostname = module.params.get('target_hostname') - target_alias = module.params.get('target_alias') - details = module.params.get('details') - disabled = module.boolean(module.params.get('disabled')) - metadata = module.params.get('metadata') - period = module.params.get('period') - timeout = module.params.get('timeout') - - state = module.params.get('state') - - setup_rax_module(module, pyrax) - - cloud_check(module, state, entity_id, label, check_type, - monitoring_zones_poll, target_hostname, target_alias, details, - disabled, metadata, period, timeout) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_mon_entity.py b/plugins/modules/rax_mon_entity.py deleted file mode 100644 index fbad9f98fc..0000000000 --- a/plugins/modules/rax_mon_entity.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_mon_entity -short_description: Create or delete a Rackspace Cloud Monitoring entity -description: -- Create or delete a Rackspace Cloud Monitoring entity, which represents a device - to monitor. Entities associate checks and alarms with a target system and - provide a convenient, centralized place to store IP addresses. Rackspace - monitoring module flow | *rax_mon_entity* -> rax_mon_check -> - rax_mon_notification -> rax_mon_notification_plan -> rax_mon_alarm -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - label: - type: str - description: - - Defines a name for this entity. Must be a non-empty string between 1 and - 255 characters long. - required: true - state: - type: str - description: - - Ensure that an entity with this C(name) exists or does not exist. - choices: ["present", "absent"] - default: present - agent_id: - type: str - description: - - Rackspace monitoring agent on the target device to which this entity is - bound. Necessary to collect C(agent.) rax_mon_checks against this entity. - named_ip_addresses: - type: dict - default: {} - description: - - Hash of IP addresses that may be referenced by name by rax_mon_checks - added to this entity. Must be a dictionary of with keys that are names - between 1 and 64 characters long, and values that are valid IPv4 or IPv6 - addresses. - metadata: - type: dict - default: {} - description: - - Hash of arbitrary C(name), C(value) pairs that are passed to associated - rax_mon_alarms. Names and values must all be between 1 and 255 characters - long. -author: Ash Wilson (@smashwilson) -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Entity example - gather_facts: false - hosts: local - connection: local - tasks: - - name: Ensure an entity exists - community.general.rax_mon_entity: - credentials: ~/.rax_pub - state: present - label: my_entity - named_ip_addresses: - web_box: 192.0.2.4 - db_box: 192.0.2.5 - meta: - hurf: durf - register: the_entity -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def cloud_monitoring(module, state, label, agent_id, named_ip_addresses, - metadata): - - if len(label) < 1 or len(label) > 255: - module.fail_json(msg='label must be between 1 and 255 characters long') - - changed = False - - cm = pyrax.cloud_monitoring - if not cm: - module.fail_json(msg='Failed to instantiate client. This typically ' - 'indicates an invalid region or an incorrectly ' - 'capitalized region name.') - - existing = [] - for entity in cm.list_entities(): - if label == entity.label: - existing.append(entity) - - entity = None - - if existing: - entity = existing[0] - - if state == 'present': - should_update = False - should_delete = False - should_create = False - - if len(existing) > 1: - module.fail_json(msg='%s existing entities have the label %s.' % - (len(existing), label)) - - if entity: - if named_ip_addresses and named_ip_addresses != entity.ip_addresses: - should_delete = should_create = True - - # Change an existing Entity, unless there's nothing to do. - should_update = agent_id and agent_id != entity.agent_id or \ - (metadata and metadata != entity.metadata) - - if should_update and not should_delete: - entity.update(agent_id, metadata) - changed = True - - if should_delete: - entity.delete() - else: - should_create = True - - if should_create: - # Create a new Entity. - entity = cm.create_entity(label=label, agent=agent_id, - ip_addresses=named_ip_addresses, - metadata=metadata) - changed = True - else: - # Delete the existing Entities. - for e in existing: - e.delete() - changed = True - - if entity: - entity_dict = { - "id": entity.id, - "name": entity.name, - "agent_id": entity.agent_id, - } - module.exit_json(changed=changed, entity=entity_dict) - else: - module.exit_json(changed=changed) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - state=dict(default='present', choices=['present', 'absent']), - label=dict(required=True), - agent_id=dict(), - named_ip_addresses=dict(type='dict', default={}), - metadata=dict(type='dict', default={}) - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - state = module.params.get('state') - - label = module.params.get('label') - agent_id = module.params.get('agent_id') - named_ip_addresses = module.params.get('named_ip_addresses') - metadata = module.params.get('metadata') - - setup_rax_module(module, pyrax) - - cloud_monitoring(module, state, label, agent_id, named_ip_addresses, metadata) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_mon_notification.py b/plugins/modules/rax_mon_notification.py deleted file mode 100644 index 7539f2a378..0000000000 --- a/plugins/modules/rax_mon_notification.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_mon_notification -short_description: Create or delete a Rackspace Cloud Monitoring notification -description: -- Create or delete a Rackspace Cloud Monitoring notification that specifies a - channel that can be used to communicate alarms, such as email, webhooks, or - PagerDuty. Rackspace monitoring module flow | rax_mon_entity -> rax_mon_check -> - *rax_mon_notification* -> rax_mon_notification_plan -> rax_mon_alarm -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - state: - type: str - description: - - Ensure that the notification with this O(label) exists or does not exist. - choices: ['present', 'absent'] - default: present - label: - type: str - description: - - Defines a friendly name for this notification. String between 1 and 255 - characters long. - required: true - notification_type: - type: str - description: - - A supported notification type. - choices: ["webhook", "email", "pagerduty"] - required: true - details: - type: dict - description: - - Dictionary of key-value pairs used to initialize the notification. - Required keys and meanings vary with notification type. See - http://docs.rackspace.com/cm/api/v1.0/cm-devguide/content/ - service-notification-types-crud.html for details. - required: true -author: Ash Wilson (@smashwilson) -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Monitoring notification example - gather_facts: false - hosts: local - connection: local - tasks: - - name: Email me when something goes wrong. - rax_mon_entity: - credentials: ~/.rax_pub - label: omg - type: email - details: - address: me@mailhost.com - register: the_notification -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def notification(module, state, label, notification_type, details): - - if len(label) < 1 or len(label) > 255: - module.fail_json(msg='label must be between 1 and 255 characters long') - - changed = False - notification = None - - cm = pyrax.cloud_monitoring - if not cm: - module.fail_json(msg='Failed to instantiate client. This typically ' - 'indicates an invalid region or an incorrectly ' - 'capitalized region name.') - - existing = [] - for n in cm.list_notifications(): - if n.label == label: - existing.append(n) - - if existing: - notification = existing[0] - - if state == 'present': - should_update = False - should_delete = False - should_create = False - - if len(existing) > 1: - module.fail_json(msg='%s existing notifications are labelled %s.' % - (len(existing), label)) - - if notification: - should_delete = (notification_type != notification.type) - - should_update = (details != notification.details) - - if should_update and not should_delete: - notification.update(details=notification.details) - changed = True - - if should_delete: - notification.delete() - else: - should_create = True - - if should_create: - notification = cm.create_notification(notification_type, - label=label, details=details) - changed = True - else: - for n in existing: - n.delete() - changed = True - - if notification: - notification_dict = { - "id": notification.id, - "type": notification.type, - "label": notification.label, - "details": notification.details - } - module.exit_json(changed=changed, notification=notification_dict) - else: - module.exit_json(changed=changed) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - state=dict(default='present', choices=['present', 'absent']), - label=dict(required=True), - notification_type=dict(required=True, choices=['webhook', 'email', 'pagerduty']), - details=dict(required=True, type='dict') - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - state = module.params.get('state') - - label = module.params.get('label') - notification_type = module.params.get('notification_type') - details = module.params.get('details') - - setup_rax_module(module, pyrax) - - notification(module, state, label, notification_type, details) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_mon_notification_plan.py b/plugins/modules/rax_mon_notification_plan.py deleted file mode 100644 index 31647304b9..0000000000 --- a/plugins/modules/rax_mon_notification_plan.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_mon_notification_plan -short_description: Create or delete a Rackspace Cloud Monitoring notification - plan. -description: -- Create or delete a Rackspace Cloud Monitoring notification plan by - associating existing rax_mon_notifications with severity levels. Rackspace - monitoring module flow | rax_mon_entity -> rax_mon_check -> - rax_mon_notification -> *rax_mon_notification_plan* -> rax_mon_alarm -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - state: - type: str - description: - - Ensure that the notification plan with this O(label) exists or does not - exist. - choices: ['present', 'absent'] - default: present - label: - type: str - description: - - Defines a friendly name for this notification plan. String between 1 and - 255 characters long. - required: true - critical_state: - type: list - elements: str - description: - - Notification list to use when the alarm state is CRITICAL. Must be an - array of valid rax_mon_notification ids. - warning_state: - type: list - elements: str - description: - - Notification list to use when the alarm state is WARNING. Must be an array - of valid rax_mon_notification ids. - ok_state: - type: list - elements: str - description: - - Notification list to use when the alarm state is OK. Must be an array of - valid rax_mon_notification ids. -author: Ash Wilson (@smashwilson) -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Example notification plan - gather_facts: false - hosts: local - connection: local - tasks: - - name: Establish who gets called when. - community.general.rax_mon_notification_plan: - credentials: ~/.rax_pub - state: present - label: defcon1 - critical_state: - - "{{ everyone['notification']['id'] }}" - warning_state: - - "{{ opsfloor['notification']['id'] }}" - register: defcon1 -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def notification_plan(module, state, label, critical_state, warning_state, ok_state): - - if len(label) < 1 or len(label) > 255: - module.fail_json(msg='label must be between 1 and 255 characters long') - - changed = False - notification_plan = None - - cm = pyrax.cloud_monitoring - if not cm: - module.fail_json(msg='Failed to instantiate client. This typically ' - 'indicates an invalid region or an incorrectly ' - 'capitalized region name.') - - existing = [] - for n in cm.list_notification_plans(): - if n.label == label: - existing.append(n) - - if existing: - notification_plan = existing[0] - - if state == 'present': - should_create = False - should_delete = False - - if len(existing) > 1: - module.fail_json(msg='%s notification plans are labelled %s.' % - (len(existing), label)) - - if notification_plan: - should_delete = (critical_state and critical_state != notification_plan.critical_state) or \ - (warning_state and warning_state != notification_plan.warning_state) or \ - (ok_state and ok_state != notification_plan.ok_state) - - if should_delete: - notification_plan.delete() - should_create = True - else: - should_create = True - - if should_create: - notification_plan = cm.create_notification_plan(label=label, - critical_state=critical_state, - warning_state=warning_state, - ok_state=ok_state) - changed = True - else: - for np in existing: - np.delete() - changed = True - - if notification_plan: - notification_plan_dict = { - "id": notification_plan.id, - "critical_state": notification_plan.critical_state, - "warning_state": notification_plan.warning_state, - "ok_state": notification_plan.ok_state, - "metadata": notification_plan.metadata - } - module.exit_json(changed=changed, notification_plan=notification_plan_dict) - else: - module.exit_json(changed=changed) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - state=dict(default='present', choices=['present', 'absent']), - label=dict(required=True), - critical_state=dict(type='list', elements='str'), - warning_state=dict(type='list', elements='str'), - ok_state=dict(type='list', elements='str'), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - state = module.params.get('state') - - label = module.params.get('label') - critical_state = module.params.get('critical_state') - warning_state = module.params.get('warning_state') - ok_state = module.params.get('ok_state') - - setup_rax_module(module, pyrax) - - notification_plan(module, state, label, critical_state, warning_state, ok_state) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_network.py b/plugins/modules/rax_network.py deleted file mode 100644 index 22f148366e..0000000000 --- a/plugins/modules/rax_network.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_network -short_description: Create / delete an isolated network in Rackspace Public Cloud -description: - - creates / deletes a Rackspace Public Cloud isolated network. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - label: - type: str - description: - - Label (name) to give the network - required: true - cidr: - type: str - description: - - cidr of the network being created -author: - - "Christopher H. Laco (@claco)" - - "Jesse Keating (@omgjlk)" -extends_documentation_fragment: - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build an Isolated Network - gather_facts: false - - tasks: - - name: Network create request - local_action: - module: rax_network - credentials: ~/.raxpub - label: my-net - cidr: 192.168.3.0/24 - state: present -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def cloud_network(module, state, label, cidr): - changed = False - network = None - networks = [] - - if not pyrax.cloud_networks: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if state == 'present': - if not cidr: - module.fail_json(msg='missing required arguments: cidr') - - try: - network = pyrax.cloud_networks.find_network_by_label(label) - except pyrax.exceptions.NetworkNotFound: - try: - network = pyrax.cloud_networks.create(label, cidr=cidr) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - elif state == 'absent': - try: - network = pyrax.cloud_networks.find_network_by_label(label) - network.delete() - changed = True - except pyrax.exceptions.NetworkNotFound: - pass - except Exception as e: - module.fail_json(msg='%s' % e.message) - - if network: - instance = dict(id=network.id, - label=network.label, - cidr=network.cidr) - networks.append(instance) - - module.exit_json(changed=changed, networks=networks) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - state=dict(default='present', - choices=['present', 'absent']), - label=dict(required=True), - cidr=dict() - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - state = module.params.get('state') - label = module.params.get('label') - cidr = module.params.get('cidr') - - setup_rax_module(module, pyrax) - - cloud_network(module, state, label, cidr) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_queue.py b/plugins/modules/rax_queue.py deleted file mode 100644 index 00f730b279..0000000000 --- a/plugins/modules/rax_queue.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_queue -short_description: Create / delete a queue in Rackspace Public Cloud -description: - - creates / deletes a Rackspace Public Cloud queue. -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - name: - type: str - description: - - Name to give the queue - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present -author: - - "Christopher H. Laco (@claco)" - - "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' -- name: Build a Queue - gather_facts: false - hosts: local - connection: local - tasks: - - name: Queue create request - local_action: - module: rax_queue - credentials: ~/.raxpub - name: my-queue - region: DFW - state: present - register: my_queue -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import rax_argument_spec, rax_required_together, setup_rax_module - - -def cloud_queue(module, state, name): - for arg in (state, name): - if not arg: - module.fail_json(msg='%s is required for rax_queue' % arg) - - changed = False - queues = [] - instance = {} - - cq = pyrax.queues - if not cq: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - for queue in cq.list(): - if name != queue.name: - continue - - queues.append(queue) - - if len(queues) > 1: - module.fail_json(msg='Multiple Queues were matched by name') - - if state == 'present': - if not queues: - try: - queue = cq.create(name) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - queue = queues[0] - - instance = dict(name=queue.name) - result = dict(changed=changed, queue=instance) - module.exit_json(**result) - - elif state == 'absent': - if queues: - queue = queues[0] - try: - queue.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, queue=instance) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - name=dict(), - state=dict(default='present', choices=['present', 'absent']), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together() - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - name = module.params.get('name') - state = module.params.get('state') - - setup_rax_module(module, pyrax) - - cloud_queue(module, state, name) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_scaling_group.py b/plugins/modules/rax_scaling_group.py deleted file mode 100644 index f4bb790255..0000000000 --- a/plugins/modules/rax_scaling_group.py +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_scaling_group -short_description: Manipulate Rackspace Cloud Autoscale Groups -description: - - Manipulate Rackspace Cloud Autoscale Groups -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - config_drive: - description: - - Attach read-only configuration drive to server as label config-2 - type: bool - default: false - cooldown: - type: int - description: - - The period of time, in seconds, that must pass before any scaling can - occur after the previous scaling. Must be an integer between 0 and - 86400 (24 hrs). - default: 300 - disk_config: - type: str - description: - - Disk partitioning strategy - - If not specified, it will fallback to V(auto). - choices: - - auto - - manual - files: - type: dict - default: {} - description: - - 'Files to insert into the instance. Hash of C(remotepath: localpath)' - flavor: - type: str - description: - - flavor to use for the instance - required: true - image: - type: str - description: - - image to use for the instance. Can be an C(id), C(human_id) or C(name). - required: true - key_name: - type: str - description: - - key pair to use on the instance - loadbalancers: - type: list - elements: dict - description: - - List of load balancer C(id) and C(port) hashes - max_entities: - type: int - description: - - The maximum number of entities that are allowed in the scaling group. - Must be an integer between 0 and 1000. - required: true - meta: - type: dict - default: {} - description: - - A hash of metadata to associate with the instance - min_entities: - type: int - description: - - The minimum number of entities that are allowed in the scaling group. - Must be an integer between 0 and 1000. - required: true - name: - type: str - description: - - Name to give the scaling group - required: true - networks: - type: list - elements: str - description: - - The network to attach to the instances. If specified, you must include - ALL networks including the public and private interfaces. Can be C(id) - or C(label). - default: - - public - - private - server_name: - type: str - description: - - The base name for servers created by Autoscale - required: true - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present - user_data: - type: str - description: - - Data to be uploaded to the servers config drive. This option implies - O(config_drive). Can be a file path or a string - wait: - description: - - wait for the scaling group to finish provisioning the minimum amount of - servers - type: bool - default: false - wait_timeout: - type: int - description: - - how long before wait gives up, in seconds - default: 300 -author: "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' ---- -- hosts: localhost - gather_facts: false - connection: local - tasks: - - community.general.rax_scaling_group: - credentials: ~/.raxpub - region: ORD - cooldown: 300 - flavor: performance1-1 - image: bb02b1a3-bc77-4d17-ab5b-421d89850fca - min_entities: 5 - max_entities: 10 - name: ASG Test - server_name: asgtest - loadbalancers: - - id: 228385 - port: 80 - register: asg -''' - -import base64 -import json -import os -import time - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import ( - rax_argument_spec, rax_find_image, rax_find_network, - rax_required_together, rax_to_dict, setup_rax_module, - rax_scaling_group_personality_file, -) -from ansible.module_utils.six import string_types - - -def rax_asg(module, cooldown=300, disk_config=None, files=None, flavor=None, - image=None, key_name=None, loadbalancers=None, meta=None, - min_entities=0, max_entities=0, name=None, networks=None, - server_name=None, state='present', user_data=None, - config_drive=False, wait=True, wait_timeout=300): - files = {} if files is None else files - loadbalancers = [] if loadbalancers is None else loadbalancers - meta = {} if meta is None else meta - networks = [] if networks is None else networks - - changed = False - - au = pyrax.autoscale - if not au: - module.fail_json(msg='Failed to instantiate clients. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - if user_data: - config_drive = True - - if user_data and os.path.isfile(user_data): - try: - f = open(user_data) - user_data = f.read() - f.close() - except Exception as e: - module.fail_json(msg='Failed to load %s' % user_data) - - if state == 'present': - # Normalize and ensure all metadata values are strings - if meta: - for k, v in meta.items(): - if isinstance(v, list): - meta[k] = ','.join(['%s' % i for i in v]) - elif isinstance(v, dict): - meta[k] = json.dumps(v) - elif not isinstance(v, string_types): - meta[k] = '%s' % v - - if image: - image = rax_find_image(module, pyrax, image) - - nics = [] - if networks: - for network in networks: - nics.extend(rax_find_network(module, pyrax, network)) - - for nic in nics: - # pyrax is currently returning net-id, but we need uuid - # this check makes this forward compatible for a time when - # pyrax uses uuid instead - if nic.get('net-id'): - nic.update(uuid=nic['net-id']) - del nic['net-id'] - - # Handle the file contents - personality = rax_scaling_group_personality_file(module, files) - - lbs = [] - if loadbalancers: - for lb in loadbalancers: - try: - lb_id = int(lb.get('id')) - except (ValueError, TypeError): - module.fail_json(msg='Load balancer ID is not an integer: ' - '%s' % lb.get('id')) - try: - port = int(lb.get('port')) - except (ValueError, TypeError): - module.fail_json(msg='Load balancer port is not an ' - 'integer: %s' % lb.get('port')) - if not lb_id or not port: - continue - lbs.append((lb_id, port)) - - try: - sg = au.find(name=name) - except pyrax.exceptions.NoUniqueMatch as e: - module.fail_json(msg='%s' % e.message) - except pyrax.exceptions.NotFound: - try: - sg = au.create(name, cooldown=cooldown, - min_entities=min_entities, - max_entities=max_entities, - launch_config_type='launch_server', - server_name=server_name, image=image, - flavor=flavor, disk_config=disk_config, - metadata=meta, personality=personality, - networks=nics, load_balancers=lbs, - key_name=key_name, config_drive=config_drive, - user_data=user_data) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - if not changed: - # Scaling Group Updates - group_args = {} - if cooldown != sg.cooldown: - group_args['cooldown'] = cooldown - - if min_entities != sg.min_entities: - group_args['min_entities'] = min_entities - - if max_entities != sg.max_entities: - group_args['max_entities'] = max_entities - - if group_args: - changed = True - sg.update(**group_args) - - # Launch Configuration Updates - lc = sg.get_launch_config() - lc_args = {} - if server_name != lc.get('name'): - lc_args['server_name'] = server_name - - if image != lc.get('image'): - lc_args['image'] = image - - if flavor != lc.get('flavor'): - lc_args['flavor'] = flavor - - disk_config = disk_config or 'AUTO' - if ((disk_config or lc.get('disk_config')) and - disk_config != lc.get('disk_config', 'AUTO')): - lc_args['disk_config'] = disk_config - - if (meta or lc.get('meta')) and meta != lc.get('metadata'): - lc_args['metadata'] = meta - - test_personality = [] - for p in personality: - test_personality.append({ - 'path': p['path'], - 'contents': base64.b64encode(p['contents']) - }) - if ((test_personality or lc.get('personality')) and - test_personality != lc.get('personality')): - lc_args['personality'] = personality - - if nics != lc.get('networks'): - lc_args['networks'] = nics - - if lbs != lc.get('load_balancers'): - # Work around for https://github.com/rackspace/pyrax/pull/393 - lc_args['load_balancers'] = sg.manager._resolve_lbs(lbs) - - if key_name != lc.get('key_name'): - lc_args['key_name'] = key_name - - if config_drive != lc.get('config_drive', False): - lc_args['config_drive'] = config_drive - - if (user_data and - base64.b64encode(user_data) != lc.get('user_data')): - lc_args['user_data'] = user_data - - if lc_args: - # Work around for https://github.com/rackspace/pyrax/pull/389 - if 'flavor' not in lc_args: - lc_args['flavor'] = lc.get('flavor') - changed = True - sg.update_launch_config(**lc_args) - - sg.get() - - if wait: - end_time = time.time() + wait_timeout - infinite = wait_timeout == 0 - while infinite or time.time() < end_time: - state = sg.get_state() - if state["pending_capacity"] == 0: - break - - time.sleep(5) - - module.exit_json(changed=changed, autoscale_group=rax_to_dict(sg)) - - else: - try: - sg = au.find(name=name) - sg.delete() - changed = True - except pyrax.exceptions.NotFound as e: - sg = {} - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, autoscale_group=rax_to_dict(sg)) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - config_drive=dict(default=False, type='bool'), - cooldown=dict(type='int', default=300), - disk_config=dict(choices=['auto', 'manual']), - files=dict(type='dict', default={}), - flavor=dict(required=True), - image=dict(required=True), - key_name=dict(), - loadbalancers=dict(type='list', elements='dict'), - meta=dict(type='dict', default={}), - min_entities=dict(type='int', required=True), - max_entities=dict(type='int', required=True), - name=dict(required=True), - networks=dict(type='list', elements='str', default=['public', 'private']), - server_name=dict(required=True), - state=dict(default='present', choices=['present', 'absent']), - user_data=dict(no_log=True), - wait=dict(default=False, type='bool'), - wait_timeout=dict(default=300, type='int'), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - config_drive = module.params.get('config_drive') - cooldown = module.params.get('cooldown') - disk_config = module.params.get('disk_config') - if disk_config: - disk_config = disk_config.upper() - files = module.params.get('files') - flavor = module.params.get('flavor') - image = module.params.get('image') - key_name = module.params.get('key_name') - loadbalancers = module.params.get('loadbalancers') - meta = module.params.get('meta') - min_entities = module.params.get('min_entities') - max_entities = module.params.get('max_entities') - name = module.params.get('name') - networks = module.params.get('networks') - server_name = module.params.get('server_name') - state = module.params.get('state') - user_data = module.params.get('user_data') - - if not 0 <= min_entities <= 1000 or not 0 <= max_entities <= 1000: - module.fail_json(msg='min_entities and max_entities must be an ' - 'integer between 0 and 1000') - - if not 0 <= cooldown <= 86400: - module.fail_json(msg='cooldown must be an integer between 0 and 86400') - - setup_rax_module(module, pyrax) - - rax_asg(module, cooldown=cooldown, disk_config=disk_config, - files=files, flavor=flavor, image=image, meta=meta, - key_name=key_name, loadbalancers=loadbalancers, - min_entities=min_entities, max_entities=max_entities, - name=name, networks=networks, server_name=server_name, - state=state, config_drive=config_drive, user_data=user_data) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/rax_scaling_policy.py b/plugins/modules/rax_scaling_policy.py deleted file mode 100644 index 2869a69105..0000000000 --- a/plugins/modules/rax_scaling_policy.py +++ /dev/null @@ -1,294 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- -module: rax_scaling_policy -short_description: Manipulate Rackspace Cloud Autoscale Scaling Policy -description: - - Manipulate Rackspace Cloud Autoscale Scaling Policy -attributes: - check_mode: - support: none - diff_mode: - support: none -options: - at: - type: str - description: - - The UTC time when this policy will be executed. The time must be - formatted according to C(yyyy-MM-dd'T'HH:mm:ss.SSS) such as - V(2013-05-19T08:07:08Z) - change: - type: int - description: - - The change, either as a number of servers or as a percentage, to make - in the scaling group. If this is a percentage, you must set - O(is_percent) to V(true) also. - cron: - type: str - description: - - The time when the policy will be executed, as a cron entry. For - example, if this is parameter is set to V(1 0 * * *). - cooldown: - type: int - description: - - The period of time, in seconds, that must pass before any scaling can - occur after the previous scaling. Must be an integer between 0 and - 86400 (24 hrs). - default: 300 - desired_capacity: - type: int - description: - - The desired server capacity of the scaling the group; that is, how - many servers should be in the scaling group. - is_percent: - description: - - Whether the value in O(change) is a percent value - default: false - type: bool - name: - type: str - description: - - Name to give the policy - required: true - policy_type: - type: str - description: - - The type of policy that will be executed for the current release. - choices: - - webhook - - schedule - required: true - scaling_group: - type: str - description: - - Name of the scaling group that this policy will be added to - required: true - state: - type: str - description: - - Indicate desired state of the resource - choices: - - present - - absent - default: present -author: "Matt Martz (@sivel)" -extends_documentation_fragment: - - community.general.rackspace - - community.general.rackspace.openstack - - community.general.attributes - -''' - -EXAMPLES = ''' ---- -- hosts: localhost - gather_facts: false - connection: local - tasks: - - community.general.rax_scaling_policy: - credentials: ~/.raxpub - region: ORD - at: '2013-05-19T08:07:08Z' - change: 25 - cooldown: 300 - is_percent: true - name: ASG Test Policy - at - policy_type: schedule - scaling_group: ASG Test - register: asps_at - - - community.general.rax_scaling_policy: - credentials: ~/.raxpub - region: ORD - cron: '1 0 * * *' - change: 25 - cooldown: 300 - is_percent: true - name: ASG Test Policy - cron - policy_type: schedule - scaling_group: ASG Test - register: asp_cron - - - community.general.rax_scaling_policy: - credentials: ~/.raxpub - region: ORD - cooldown: 300 - desired_capacity: 5 - name: ASG Test Policy - webhook - policy_type: webhook - scaling_group: ASG Test - register: asp_webhook -''' - -try: - import pyrax - HAS_PYRAX = True -except ImportError: - HAS_PYRAX = False - -from ansible.module_utils.basic import AnsibleModule -from ansible_collections.community.general.plugins.module_utils.rax import (UUID, rax_argument_spec, rax_required_together, rax_to_dict, - setup_rax_module) - - -def rax_asp(module, at=None, change=0, cron=None, cooldown=300, - desired_capacity=0, is_percent=False, name=None, - policy_type=None, scaling_group=None, state='present'): - changed = False - - au = pyrax.autoscale - if not au: - module.fail_json(msg='Failed to instantiate client. This ' - 'typically indicates an invalid region or an ' - 'incorrectly capitalized region name.') - - try: - UUID(scaling_group) - except ValueError: - try: - sg = au.find(name=scaling_group) - except Exception as e: - module.fail_json(msg='%s' % e.message) - else: - try: - sg = au.get(scaling_group) - except Exception as e: - module.fail_json(msg='%s' % e.message) - - if state == 'present': - policies = filter(lambda p: name == p.name, sg.list_policies()) - if len(policies) > 1: - module.fail_json(msg='No unique policy match found by name') - if at: - args = dict(at=at) - elif cron: - args = dict(cron=cron) - else: - args = None - - if not policies: - try: - policy = sg.add_policy(name, policy_type=policy_type, - cooldown=cooldown, change=change, - is_percent=is_percent, - desired_capacity=desired_capacity, - args=args) - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - else: - policy = policies[0] - kwargs = {} - if policy_type != policy.type: - kwargs['policy_type'] = policy_type - - if cooldown != policy.cooldown: - kwargs['cooldown'] = cooldown - - if hasattr(policy, 'change') and change != policy.change: - kwargs['change'] = change - - if hasattr(policy, 'changePercent') and is_percent is False: - kwargs['change'] = change - kwargs['is_percent'] = False - elif hasattr(policy, 'change') and is_percent is True: - kwargs['change'] = change - kwargs['is_percent'] = True - - if hasattr(policy, 'desiredCapacity') and change: - kwargs['change'] = change - elif ((hasattr(policy, 'change') or - hasattr(policy, 'changePercent')) and desired_capacity): - kwargs['desired_capacity'] = desired_capacity - - if hasattr(policy, 'args') and args != policy.args: - kwargs['args'] = args - - if kwargs: - policy.update(**kwargs) - changed = True - - policy.get() - - module.exit_json(changed=changed, autoscale_policy=rax_to_dict(policy)) - - else: - try: - policies = filter(lambda p: name == p.name, sg.list_policies()) - if len(policies) > 1: - module.fail_json(msg='No unique policy match found by name') - elif not policies: - policy = {} - else: - policy.delete() - changed = True - except Exception as e: - module.fail_json(msg='%s' % e.message) - - module.exit_json(changed=changed, autoscale_policy=rax_to_dict(policy)) - - -def main(): - argument_spec = rax_argument_spec() - argument_spec.update( - dict( - at=dict(), - change=dict(type='int'), - cron=dict(), - cooldown=dict(type='int', default=300), - desired_capacity=dict(type='int'), - is_percent=dict(type='bool', default=False), - name=dict(required=True), - policy_type=dict(required=True, choices=['webhook', 'schedule']), - scaling_group=dict(required=True), - state=dict(default='present', choices=['present', 'absent']), - ) - ) - - module = AnsibleModule( - argument_spec=argument_spec, - required_together=rax_required_together(), - mutually_exclusive=[ - ['cron', 'at'], - ['change', 'desired_capacity'], - ] - ) - - if not HAS_PYRAX: - module.fail_json(msg='pyrax is required for this module') - - at = module.params.get('at') - change = module.params.get('change') - cron = module.params.get('cron') - cooldown = module.params.get('cooldown') - desired_capacity = module.params.get('desired_capacity') - is_percent = module.params.get('is_percent') - name = module.params.get('name') - policy_type = module.params.get('policy_type') - scaling_group = module.params.get('scaling_group') - state = module.params.get('state') - - if (at or cron) and policy_type == 'webhook': - module.fail_json(msg='policy_type=schedule is required for a time ' - 'based policy') - - setup_rax_module(module, pyrax) - - rax_asp(module, at=at, change=change, cron=cron, cooldown=cooldown, - desired_capacity=desired_capacity, is_percent=is_percent, - name=name, policy_type=policy_type, scaling_group=scaling_group, - state=state) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/redfish_command.py b/plugins/modules/redfish_command.py index e66380493c..f9b0c8bd3b 100644 --- a/plugins/modules/redfish_command.py +++ b/plugins/modules/redfish_command.py @@ -109,9 +109,10 @@ options: timeout: description: - Timeout in seconds for HTTP requests to OOB controller. - - The default value for this param is C(10) but that is being deprecated - and it will be replaced with C(60) in community.general 9.0.0. + - The default value for this parameter changed from V(10) to V(60) + in community.general 9.0.0. type: int + default: 60 boot_override_mode: description: - Boot mode when using an override. @@ -281,6 +282,37 @@ options: - BIOS attributes that needs to be verified in the given server. type: dict version_added: 6.4.0 + reset_to_defaults_mode: + description: + - Mode to apply when reseting to default. + type: str + choices: [ ResetAll, PreserveNetworkAndUsers, PreserveNetwork ] + version_added: 8.6.0 + wait: + required: false + description: + - Block until the service is ready again. + type: bool + default: false + version_added: 9.1.0 + wait_timeout: + required: false + description: + - How long to block until the service is ready again before giving up. + type: int + default: 120 + version_added: 9.1.0 + ciphers: + required: false + description: + - SSL/TLS Ciphers to use for the request. + - 'When a list is provided, all ciphers are joined in order with V(:).' + - See the L(OpenSSL Cipher List Format,https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html#CIPHER-LIST-FORMAT) + for more details. + - The available ciphers is dependent on the Python and OpenSSL/LibreSSL versions. + type: list + elements: str + version_added: 9.2.0 author: - "Jose Delarosa (@jose-delarosa)" @@ -678,6 +710,16 @@ EXAMPLES = ''' username: "{{ username }}" password: "{{ password }}" + - name: Restart manager power gracefully and wait for it to be available + community.general.redfish_command: + category: Manager + command: GracefulRestart + resource_id: BMC + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + wait: True + - name: Restart manager power gracefully community.general.redfish_command: category: Manager @@ -714,6 +756,13 @@ EXAMPLES = ''' command: PowerReboot resource_id: BMC + - name: Factory reset manager to defaults + community.general.redfish_command: + category: Manager + command: ResetToDefaults + resource_id: BMC + reset_to_defaults_mode: ResetAll + - name: Verify BIOS attributes community.general.redfish_command: category: Systems @@ -764,6 +813,7 @@ CATEGORY_COMMANDS_ALL = { "UpdateAccountServiceProperties"], "Sessions": ["ClearSessions", "CreateSession", "DeleteSession"], "Manager": ["GracefulRestart", "ClearLogs", "VirtualMediaInsert", + "ResetToDefaults", "VirtualMediaEject", "PowerOn", "PowerForceOff", "PowerForceRestart", "PowerGracefulRestart", "PowerGracefulShutdown", "PowerReboot"], "Update": ["SimpleUpdate", "MultipartHTTPPushUpdate", "PerformRequestedOperations"], @@ -791,7 +841,7 @@ def main(): update_username=dict(type='str', aliases=["account_updatename"]), account_properties=dict(type='dict', default={}), bootdevice=dict(), - timeout=dict(type='int'), + timeout=dict(type='int', default=60), uefi_target=dict(), boot_next=dict(), boot_override_mode=dict(choices=['Legacy', 'UEFI']), @@ -825,7 +875,11 @@ def main(): ) ), strip_etag_quotes=dict(type='bool', default=False), - bios_attributes=dict(type="dict") + reset_to_defaults_mode=dict(choices=['ResetAll', 'PreserveNetworkAndUsers', 'PreserveNetwork']), + bios_attributes=dict(type="dict"), + wait=dict(type='bool', default=False), + wait_timeout=dict(type='int', default=120), + ciphers=dict(type='list', elements='str'), ), required_together=[ ('username', 'password'), @@ -839,16 +893,6 @@ def main(): supports_check_mode=False ) - if module.params['timeout'] is None: - timeout = 10 - module.deprecate( - 'The default value {0} for parameter param1 is being deprecated and it will be replaced by {1}'.format( - 10, 60 - ), - version='9.0.0', - collection_name='community.general' - ) - category = module.params['category'] command_list = module.params['command'] @@ -904,10 +948,14 @@ def main(): # BIOS Attributes options bios_attributes = module.params['bios_attributes'] + # ciphers + ciphers = module.params['ciphers'] + # Build root URI root_uri = "https://" + module.params['baseuri'] rf_utils = RedfishUtils(creds, root_uri, timeout, module, - resource_id=resource_id, data_modification=True, strip_etag_quotes=strip_etag_quotes) + resource_id=resource_id, data_modification=True, strip_etag_quotes=strip_etag_quotes, + ciphers=ciphers) # Check that Category is valid if category not in CATEGORY_COMMANDS_ALL: @@ -1010,13 +1058,15 @@ def main(): command = 'PowerGracefulRestart' if command.startswith('Power'): - result = rf_utils.manage_manager_power(command) + result = rf_utils.manage_manager_power(command, module.params['wait'], module.params['wait_timeout']) elif command == 'ClearLogs': result = rf_utils.clear_logs() elif command == 'VirtualMediaInsert': result = rf_utils.virtual_media_insert(virtual_media, category) elif command == 'VirtualMediaEject': result = rf_utils.virtual_media_eject(virtual_media, category) + elif command == 'ResetToDefaults': + result = rf_utils.manager_reset_to_defaults(module.params['reset_to_defaults_mode']) elif category == "Update": # execute only if we find UpdateService resources diff --git a/plugins/modules/redfish_config.py b/plugins/modules/redfish_config.py index 1fea9e7cd1..25f3cffdb4 100644 --- a/plugins/modules/redfish_config.py +++ b/plugins/modules/redfish_config.py @@ -64,9 +64,10 @@ options: timeout: description: - Timeout in seconds for HTTP requests to OOB controller. - - The default value for this param is C(10) but that is being deprecated - and it will be replaced with C(60) in community.general 9.0.0. + - The default value for this parameter changed from V(10) to V(60) + in community.general 9.0.0. type: int + default: 60 boot_order: required: false description: @@ -166,6 +167,18 @@ options: type: dict default: {} version_added: '7.5.0' + ciphers: + required: false + description: + - SSL/TLS Ciphers to use for the request. + - 'When a list is provided, all ciphers are joined in order with V(:).' + - See the L(OpenSSL Cipher List Format,https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html#CIPHER-LIST-FORMAT) + for more details. + - The available ciphers is dependent on the Python and OpenSSL/LibreSSL versions. + type: list + elements: str + version_added: 9.2.0 + author: - "Jose Delarosa (@jose-delarosa)" - "T S Kushal (@TSKushal)" @@ -384,7 +397,7 @@ def main(): password=dict(no_log=True), auth_token=dict(no_log=True), bios_attributes=dict(type='dict', default={}), - timeout=dict(type='int'), + timeout=dict(type='int', default=60), boot_order=dict(type='list', elements='str', default=[]), network_protocols=dict( type='dict', @@ -404,7 +417,8 @@ def main(): storage_subsystem_id=dict(type='str', default=''), volume_ids=dict(type='list', default=[], elements='str'), secure_boot_enable=dict(type='bool', default=True), - volume_details=dict(type='dict', default={}) + volume_details=dict(type='dict', default={}), + ciphers=dict(type='list', elements='str'), ), required_together=[ ('username', 'password'), @@ -418,16 +432,6 @@ def main(): supports_check_mode=False ) - if module.params['timeout'] is None: - timeout = 10 - module.deprecate( - 'The default value {0} for parameter param1 is being deprecated and it will be replaced by {1}'.format( - 10, 60 - ), - version='9.0.0', - collection_name='community.general' - ) - category = module.params['category'] command_list = module.params['command'] @@ -478,10 +482,14 @@ def main(): volume_details = module.params['volume_details'] storage_subsystem_id = module.params['storage_subsystem_id'] + # ciphers + ciphers = module.params['ciphers'] + # Build root URI root_uri = "https://" + module.params['baseuri'] rf_utils = RedfishUtils(creds, root_uri, timeout, module, - resource_id=resource_id, data_modification=True, strip_etag_quotes=strip_etag_quotes) + resource_id=resource_id, data_modification=True, strip_etag_quotes=strip_etag_quotes, + ciphers=ciphers) # Check that Category is valid if category not in CATEGORY_COMMANDS_ALL: diff --git a/plugins/modules/redfish_info.py b/plugins/modules/redfish_info.py index 3deaade229..32e802e3e8 100644 --- a/plugins/modules/redfish_info.py +++ b/plugins/modules/redfish_info.py @@ -63,15 +63,27 @@ options: timeout: description: - Timeout in seconds for HTTP requests to OOB controller. - - The default value for this param is C(10) but that is being deprecated - and it will be replaced with C(60) in community.general 9.0.0. + - The default value for this parameter changed from V(10) to V(60) + in community.general 9.0.0. type: int + default: 60 update_handle: required: false description: - Handle to check the status of an update in progress. type: str version_added: '6.1.0' + ciphers: + required: false + description: + - SSL/TLS Ciphers to use for the request. + - 'When a list is provided, all ciphers are joined in order with V(:).' + - See the L(OpenSSL Cipher List Format,https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html#CIPHER-LIST-FORMAT) + for more details. + - The available ciphers is dependent on the Python and OpenSSL/LibreSSL versions. + type: list + elements: str + version_added: 9.2.0 author: "Jose Delarosa (@jose-delarosa)" ''' @@ -358,6 +370,16 @@ EXAMPLES = ''' baseuri: "{{ baseuri }}" username: "{{ username }}" password: "{{ password }}" + + - name: Check the availability of the service with a timeout of 5 seconds + community.general.redfish_info: + category: Service + command: CheckAvailability + baseuri: "{{ baseuri }}" + username: "{{ username }}" + password: "{{ password }}" + timeout: 5 + register: result ''' RETURN = ''' @@ -384,6 +406,7 @@ CATEGORY_COMMANDS_ALL = { "GetUpdateStatus"], "Manager": ["GetManagerNicInventory", "GetVirtualMedia", "GetLogs", "GetNetworkProtocols", "GetHealthReport", "GetHostInterfaces", "GetManagerInventory", "GetServiceIdentification"], + "Service": ["CheckAvailability"], } CATEGORY_COMMANDS_DEFAULT = { @@ -392,7 +415,8 @@ CATEGORY_COMMANDS_DEFAULT = { "Accounts": "ListUsers", "Update": "GetFirmwareInventory", "Sessions": "GetSessions", - "Manager": "GetManagerNicInventory" + "Manager": "GetManagerNicInventory", + "Service": "CheckAvailability", } @@ -407,9 +431,10 @@ def main(): username=dict(), password=dict(no_log=True), auth_token=dict(no_log=True), - timeout=dict(type='int'), + timeout=dict(type='int', default=60), update_handle=dict(), manager=dict(), + ciphers=dict(type='list', elements='str'), ), required_together=[ ('username', 'password'), @@ -423,16 +448,6 @@ def main(): supports_check_mode=True, ) - if module.params['timeout'] is None: - timeout = 10 - module.deprecate( - 'The default value {0} for parameter param1 is being deprecated and it will be replaced by {1}'.format( - 10, 60 - ), - version='9.0.0', - collection_name='community.general' - ) - # admin credentials used for authentication creds = {'user': module.params['username'], 'pswd': module.params['password'], @@ -447,9 +462,12 @@ def main(): # manager manager = module.params['manager'] + # ciphers + ciphers = module.params['ciphers'] + # Build root URI root_uri = "https://" + module.params['baseuri'] - rf_utils = RedfishUtils(creds, root_uri, timeout, module) + rf_utils = RedfishUtils(creds, root_uri, timeout, module, ciphers=ciphers) # Build Category list if "all" in module.params['category']: @@ -482,7 +500,13 @@ def main(): module.fail_json(msg="Invalid Category: %s" % category) # Organize by Categories / Commands - if category == "Systems": + if category == "Service": + # service-level commands are always available + for command in command_list: + if command == "CheckAvailability": + result["service"] = rf_utils.check_service_availability() + + elif category == "Systems": # execute only if we find a Systems resource resource = rf_utils._find_systems_resource() if resource['ret'] is False: diff --git a/plugins/modules/redhat_subscription.py b/plugins/modules/redhat_subscription.py index d4b47d5d50..4a7aac483e 100644 --- a/plugins/modules/redhat_subscription.py +++ b/plugins/modules/redhat_subscription.py @@ -123,10 +123,9 @@ options: description: - Upon successful registration, auto-consume available subscriptions - | - Please note that the alias O(autosubscribe) will be removed in + Please note that the alias O(ignore:autosubscribe) was removed in community.general 9.0.0. type: bool - aliases: [autosubscribe] activationkey: description: - supply an activation key for use with registration @@ -1106,17 +1105,7 @@ def main(): 'server_port': {}, 'rhsm_baseurl': {}, 'rhsm_repo_ca_cert': {}, - 'auto_attach': { - 'type': 'bool', - 'aliases': ['autosubscribe'], - 'deprecated_aliases': [ - { - 'name': 'autosubscribe', - 'version': '9.0.0', - 'collection_name': 'community.general', - }, - ], - }, + 'auto_attach': {'type': 'bool'}, 'activationkey': {'no_log': True}, 'org_id': {}, 'environment': {}, diff --git a/plugins/modules/redis.py b/plugins/modules/redis.py index 207927cb77..a30b89922c 100644 --- a/plugins/modules/redis.py +++ b/plugins/modules/redis.py @@ -132,6 +132,16 @@ EXAMPLES = ''' command: config name: lua-time-limit value: 100 + +- name: Connect using TLS and certificate authentication + community.general.redis: + command: config + name: lua-time-limit + value: 100 + tls: true + ca_certs: /etc/redis/certs/ca.crt + client_cert_file: /etc/redis/certs/redis.crt + client_key_file: /etc/redis/certs/redis.key ''' import traceback diff --git a/plugins/modules/redis_info.py b/plugins/modules/redis_info.py index f352d53d79..c75abcf212 100644 --- a/plugins/modules/redis_info.py +++ b/plugins/modules/redis_info.py @@ -30,6 +30,11 @@ options: version_added: 7.5.0 ca_certs: version_added: 7.5.0 + cluster: + default: false + description: Get informations about cluster status as RV(cluster). + type: bool + version_added: 9.1.0 seealso: - module: community.general.redis author: "Pavlo Bashynskyi (@levonet)" @@ -43,6 +48,15 @@ EXAMPLES = r''' - name: Print server information ansible.builtin.debug: var: result.info + +- name: Get server cluster information + community.general.redis_info: + cluster: true + register: result + +- name: Print server cluster information + ansible.builtin.debug: + var: result.cluster_info ''' RETURN = r''' @@ -178,6 +192,25 @@ info: "used_memory_scripts_human": "0B", "used_memory_startup": 791264 } +cluster: + description: The default set of cluster information sections U(https://redis.io/commands/cluster-info). + returned: success if O(cluster=true) + version_added: 9.1.0 + type: dict + sample: { + "cluster_state": ok, + "cluster_slots_assigned": 16384, + "cluster_slots_ok": 16384, + "cluster_slots_pfail": 0, + "cluster_slots_fail": 0, + "cluster_known_nodes": 6, + "cluster_size": 3, + "cluster_current_epoch": 6, + "cluster_my_epoch": 2, + "cluster_stats_messages_sent": 1483972, + "cluster_stats_messages_received": 1483968, + "total_cluster_links_buffer_limit_exceeded": 0 + } ''' import traceback @@ -202,14 +235,19 @@ def redis_client(**client_params): # Module execution. def main(): + module_args = dict( + cluster=dict(type='bool', default=False), + ) + module_args.update(redis_auth_argument_spec(tls_default=False)) module = AnsibleModule( - argument_spec=redis_auth_argument_spec(tls_default=False), + argument_spec=module_args, supports_check_mode=True, ) fail_imports(module, module.params['tls']) redis_params = redis_auth_params(module) + cluster = module.params['cluster'] # Connect and check client = redis_client(**redis_params) @@ -219,7 +257,13 @@ def main(): module.fail_json(msg="unable to connect to database: %s" % to_native(e), exception=traceback.format_exc()) info = client.info() - module.exit_json(changed=False, info=info) + + result = dict(changed=False, info=info) + + if cluster: + result['cluster_info'] = client.execute_command('CLUSTER INFO') + + module.exit_json(**result) if __name__ == '__main__': diff --git a/plugins/modules/riak.py b/plugins/modules/riak.py index fe295d2d6d..438263da22 100644 --- a/plugins/modules/riak.py +++ b/plugins/modules/riak.py @@ -93,7 +93,7 @@ from ansible.module_utils.urls import fetch_url def ring_check(module, riak_admin_bin): - cmd = '%s ringready' % riak_admin_bin + cmd = riak_admin_bin + ['ringready'] rc, out, err = module.run_command(cmd) if rc == 0 and 'TRUE All nodes agree on the ring' in out: return True @@ -127,6 +127,7 @@ def main(): # make sure riak commands are on the path riak_bin = module.get_bin_path('riak') riak_admin_bin = module.get_bin_path('riak-admin') + riak_admin_bin = [riak_admin_bin] if riak_admin_bin is not None else [riak_bin, 'admin'] timeout = time.time() + 120 while True: @@ -164,7 +165,7 @@ def main(): module.fail_json(msg=out) elif command == 'kv_test': - cmd = '%s test' % riak_admin_bin + cmd = riak_admin_bin + ['test'] rc, out, err = module.run_command(cmd) if rc == 0: result['kv_test'] = out @@ -175,7 +176,7 @@ def main(): if nodes.count(node_name) == 1 and len(nodes) > 1: result['join'] = 'Node is already in cluster or staged to be in cluster.' else: - cmd = '%s cluster join %s' % (riak_admin_bin, target_node) + cmd = riak_admin_bin + ['cluster', 'join', target_node] rc, out, err = module.run_command(cmd) if rc == 0: result['join'] = out @@ -184,7 +185,7 @@ def main(): module.fail_json(msg=out) elif command == 'plan': - cmd = '%s cluster plan' % riak_admin_bin + cmd = riak_admin_bin + ['cluster', 'plan'] rc, out, err = module.run_command(cmd) if rc == 0: result['plan'] = out @@ -194,7 +195,7 @@ def main(): module.fail_json(msg=out) elif command == 'commit': - cmd = '%s cluster commit' % riak_admin_bin + cmd = riak_admin_bin + ['cluster', 'commit'] rc, out, err = module.run_command(cmd) if rc == 0: result['commit'] = out @@ -206,7 +207,7 @@ def main(): if wait_for_handoffs: timeout = time.time() + wait_for_handoffs while True: - cmd = '%s transfers' % riak_admin_bin + cmd = riak_admin_bin + ['transfers'] rc, out, err = module.run_command(cmd) if 'No transfers active' in out: result['handoffs'] = 'No transfers active.' @@ -216,7 +217,7 @@ def main(): module.fail_json(msg='Timeout waiting for handoffs.') if wait_for_service: - cmd = [riak_admin_bin, 'wait_for_service', 'riak_%s' % wait_for_service, node_name] + cmd = riak_admin_bin + ['wait_for_service', 'riak_%s' % wait_for_service, node_name] rc, out, err = module.run_command(cmd) result['service'] = out diff --git a/plugins/modules/rpm_ostree_pkg.py b/plugins/modules/rpm_ostree_pkg.py index 826c33f2d1..1a02b2d71c 100644 --- a/plugins/modules/rpm_ostree_pkg.py +++ b/plugins/modules/rpm_ostree_pkg.py @@ -55,6 +55,17 @@ EXAMPLES = r''' community.general.rpm_ostree_pkg: name: nfs-utils state: absent + +# In case a different transaction is currently running the module would fail. +# Adding a delay can help mitigate this problem: +- name: Install overlay package + community.general.rpm_ostree_pkg: + name: nfs-utils + state: present + register: rpm_ostree_pkg + until: rpm_ostree_pkg is not failed + retries: 10 + dealy: 30 ''' RETURN = r''' diff --git a/plugins/modules/scaleway_compute.py b/plugins/modules/scaleway_compute.py index 7f85bc6686..d8480c199d 100644 --- a/plugins/modules/scaleway_compute.py +++ b/plugins/modules/scaleway_compute.py @@ -183,6 +183,7 @@ import datetime import time from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.datetime import now from ansible_collections.community.general.plugins.module_utils.scaleway import SCALEWAY_LOCATION, scaleway_argument_spec, Scaleway SCALEWAY_SERVER_STATES = ( @@ -235,9 +236,9 @@ def wait_to_complete_state_transition(compute_api, server, wait=None): wait_timeout = compute_api.module.params["wait_timeout"] wait_sleep_time = compute_api.module.params["wait_sleep_time"] - start = datetime.datetime.utcnow() + start = now() end = start + datetime.timedelta(seconds=wait_timeout) - while datetime.datetime.utcnow() < end: + while now() < end: compute_api.module.debug("We are going to wait for the server to finish its transition") if fetch_state(compute_api, server) not in SCALEWAY_TRANSITIONS_STATES: compute_api.module.debug("It seems that the server is not in transition anymore.") @@ -585,9 +586,11 @@ def server_attributes_should_be_changed(compute_api, target_server, wished_serve compute_api.module.debug("Checking if server attributes should be changed") compute_api.module.debug("Current Server: %s" % target_server) compute_api.module.debug("Wished Server: %s" % wished_server) - debug_dict = dict((x, (target_server[x], wished_server[x])) - for x in PATCH_MUTABLE_SERVER_ATTRIBUTES - if x in target_server and x in wished_server) + debug_dict = { + x: (target_server[x], wished_server[x]) + for x in PATCH_MUTABLE_SERVER_ATTRIBUTES + if x in target_server and x in wished_server + } compute_api.module.debug("Debug dict %s" % debug_dict) try: for key in PATCH_MUTABLE_SERVER_ATTRIBUTES: @@ -613,7 +616,7 @@ def server_change_attributes(compute_api, target_server, wished_server): # When you are working with dict, only ID matter as we ask user to put only the resource ID in the playbook if isinstance(target_server[key], dict) and "id" in target_server[key] and wished_server[key]: # Setting all key to current value except ID - key_dict = dict((x, target_server[key][x]) for x in target_server[key].keys() if x != "id") + key_dict = {x: target_server[key][x] for x in target_server[key].keys() if x != "id"} # Setting ID to the user specified ID key_dict["id"] = wished_server[key] patch_payload[key] = key_dict diff --git a/plugins/modules/scaleway_database_backup.py b/plugins/modules/scaleway_database_backup.py index 592ec0b7ff..1d0c17fb6d 100644 --- a/plugins/modules/scaleway_database_backup.py +++ b/plugins/modules/scaleway_database_backup.py @@ -170,6 +170,9 @@ import datetime import time from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) from ansible_collections.community.general.plugins.module_utils.scaleway import ( Scaleway, scaleway_argument_spec, @@ -189,9 +192,9 @@ def wait_to_complete_state_transition(module, account_api, backup=None): if backup is None or backup['status'] in stable_states: return backup - start = datetime.datetime.utcnow() + start = now() end = start + datetime.timedelta(seconds=wait_timeout) - while datetime.datetime.utcnow() < end: + while now() < end: module.debug('We are going to wait for the backup to finish its transition') response = account_api.get('/rdb/v1/regions/%s/backups/%s' % (module.params.get('region'), backup['id'])) diff --git a/plugins/modules/scaleway_ip.py b/plugins/modules/scaleway_ip.py index 1c9042742b..79f0c7e3fb 100644 --- a/plugins/modules/scaleway_ip.py +++ b/plugins/modules/scaleway_ip.py @@ -145,11 +145,11 @@ def ip_attributes_should_be_changed(api, target_ip, wished_ip): def payload_from_wished_ip(wished_ip): - return dict( - (k, v) + return { + k: v for k, v in wished_ip.items() if k != 'id' and v is not None - ) + } def present_strategy(api, wished_ip): @@ -161,8 +161,7 @@ def present_strategy(api, wished_ip): response.status_code, response.json['message'])) ips_list = response.json["ips"] - ip_lookup = dict((ip["id"], ip) - for ip in ips_list) + ip_lookup = {ip["id"]: ip for ip in ips_list} if wished_ip["id"] not in ip_lookup.keys(): changed = True @@ -212,8 +211,7 @@ def absent_strategy(api, wished_ip): api.module.fail_json(msg='Error getting IPs [{0}: {1}]'.format( status_code, response.json['message'])) - ip_lookup = dict((ip["id"], ip) - for ip in ips_list) + ip_lookup = {ip["id"]: ip for ip in ips_list} if wished_ip["id"] not in ip_lookup.keys(): return changed, {} diff --git a/plugins/modules/scaleway_lb.py b/plugins/modules/scaleway_lb.py index 3e43a8ae2b..1083b6da9e 100644 --- a/plugins/modules/scaleway_lb.py +++ b/plugins/modules/scaleway_lb.py @@ -161,6 +161,7 @@ RETURNS = ''' import datetime import time from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.datetime import now from ansible_collections.community.general.plugins.module_utils.scaleway import SCALEWAY_REGIONS, SCALEWAY_ENDPOINT, scaleway_argument_spec, Scaleway STABLE_STATES = ( @@ -208,9 +209,9 @@ def wait_to_complete_state_transition(api, lb, force_wait=False): wait_timeout = api.module.params["wait_timeout"] wait_sleep_time = api.module.params["wait_sleep_time"] - start = datetime.datetime.utcnow() + start = now() end = start + datetime.timedelta(seconds=wait_timeout) - while datetime.datetime.utcnow() < end: + while now() < end: api.module.debug("We are going to wait for the load-balancer to finish its transition") state = fetch_state(api, lb) if state in STABLE_STATES: @@ -223,10 +224,10 @@ def wait_to_complete_state_transition(api, lb, force_wait=False): def lb_attributes_should_be_changed(target_lb, wished_lb): - diff = dict((attr, wished_lb[attr]) for attr in MUTABLE_ATTRIBUTES if target_lb[attr] != wished_lb[attr]) + diff = {attr: wished_lb[attr] for attr in MUTABLE_ATTRIBUTES if target_lb[attr] != wished_lb[attr]} if diff: - return dict((attr, wished_lb[attr]) for attr in MUTABLE_ATTRIBUTES) + return {attr: wished_lb[attr] for attr in MUTABLE_ATTRIBUTES} else: return diff @@ -240,8 +241,7 @@ def present_strategy(api, wished_lb): response.status_code, response.json['message'])) lbs_list = response.json["lbs"] - lb_lookup = dict((lb["name"], lb) - for lb in lbs_list) + lb_lookup = {lb["name"]: lb for lb in lbs_list} if wished_lb["name"] not in lb_lookup.keys(): changed = True @@ -297,8 +297,7 @@ def absent_strategy(api, wished_lb): api.module.fail_json(msg='Error getting load-balancers [{0}: {1}]'.format( status_code, response.json['message'])) - lb_lookup = dict((lb["name"], lb) - for lb in lbs_list) + lb_lookup = {lb["name"]: lb for lb in lbs_list} if wished_lb["name"] not in lb_lookup.keys(): return changed, {} diff --git a/plugins/modules/scaleway_security_group.py b/plugins/modules/scaleway_security_group.py index c09bc34bad..3aee99e99a 100644 --- a/plugins/modules/scaleway_security_group.py +++ b/plugins/modules/scaleway_security_group.py @@ -135,11 +135,11 @@ from uuid import uuid4 def payload_from_security_group(security_group): - return dict( - (k, v) + return { + k: v for k, v in security_group.items() if k != 'id' and v is not None - ) + } def present_strategy(api, security_group): @@ -149,8 +149,7 @@ def present_strategy(api, security_group): if not response.ok: api.module.fail_json(msg='Error getting security groups "%s": "%s" (%s)' % (response.info['msg'], response.json['message'], response.json)) - security_group_lookup = dict((sg['name'], sg) - for sg in response.json['security_groups']) + security_group_lookup = {sg['name']: sg for sg in response.json['security_groups']} if security_group['name'] not in security_group_lookup.keys(): ret['changed'] = True @@ -181,8 +180,7 @@ def absent_strategy(api, security_group): if not response.ok: api.module.fail_json(msg='Error getting security groups "%s": "%s" (%s)' % (response.info['msg'], response.json['message'], response.json)) - security_group_lookup = dict((sg['name'], sg) - for sg in response.json['security_groups']) + security_group_lookup = {sg['name']: sg for sg in response.json['security_groups']} if security_group['name'] not in security_group_lookup.keys(): return ret diff --git a/plugins/modules/scaleway_user_data.py b/plugins/modules/scaleway_user_data.py index 08ff86a55e..601231def9 100644 --- a/plugins/modules/scaleway_user_data.py +++ b/plugins/modules/scaleway_user_data.py @@ -129,10 +129,10 @@ def core(module): compute_api.module.fail_json(msg=msg) present_user_data_keys = user_data_list.json["user_data"] - present_user_data = dict( - (key, get_user_data(compute_api=compute_api, server_id=server_id, key=key)) + present_user_data = { + key: get_user_data(compute_api=compute_api, server_id=server_id, key=key) for key in present_user_data_keys - ) + } if present_user_data == user_data: module.exit_json(changed=changed, msg=user_data_list.json) diff --git a/plugins/modules/sensu_silence.py b/plugins/modules/sensu_silence.py index 14c664755d..25dfc239eb 100644 --- a/plugins/modules/sensu_silence.py +++ b/plugins/modules/sensu_silence.py @@ -149,7 +149,7 @@ def clear(module, url, check, subscription): # Test if silence exists before clearing (rc, out, changed) = query(module, url, check, subscription) - d = dict((i['subscription'], i['check']) for i in out) + d = {i['subscription']: i['check'] for i in out} subscription_exists = subscription in d if check and subscription_exists: exists = (check == d[subscription]) diff --git a/plugins/modules/slackpkg.py b/plugins/modules/slackpkg.py index e3d7a15429..9347db1591 100644 --- a/plugins/modules/slackpkg.py +++ b/plugins/modules/slackpkg.py @@ -106,9 +106,8 @@ def remove_packages(module, slackpkg_path, packages): continue if not module.check_mode: - rc, out, err = module.run_command("%s -default_answer=y -batch=on \ - remove %s" % (slackpkg_path, - package)) + rc, out, err = module.run_command( + [slackpkg_path, "-default_answer=y", "-batch=on", "remove", package]) if not module.check_mode and query_package(module, slackpkg_path, package): @@ -132,9 +131,8 @@ def install_packages(module, slackpkg_path, packages): continue if not module.check_mode: - rc, out, err = module.run_command("%s -default_answer=y -batch=on \ - install %s" % (slackpkg_path, - package)) + rc, out, err = module.run_command( + [slackpkg_path, "-default_answer=y", "-batch=on", "install", package]) if not module.check_mode and not query_package(module, slackpkg_path, package): @@ -155,9 +153,8 @@ def upgrade_packages(module, slackpkg_path, packages): for package in packages: if not module.check_mode: - rc, out, err = module.run_command("%s -default_answer=y -batch=on \ - upgrade %s" % (slackpkg_path, - package)) + rc, out, err = module.run_command( + [slackpkg_path, "-default_answer=y", "-batch=on", "upgrade", package]) if not module.check_mode and not query_package(module, slackpkg_path, package): @@ -174,7 +171,8 @@ def upgrade_packages(module, slackpkg_path, packages): def update_cache(module, slackpkg_path): - rc, out, err = module.run_command("%s -batch=on update" % (slackpkg_path)) + rc, out, err = module.run_command( + [slackpkg_path, "-batch=on", "update"]) if rc != 0: module.fail_json(msg="Could not update package cache") diff --git a/plugins/modules/snap.py b/plugins/modules/snap.py index fd16764802..16c3aec48b 100644 --- a/plugins/modules/snap.py +++ b/plugins/modules/snap.py @@ -194,6 +194,7 @@ class Snap(StateModuleHelper): }, supports_check_mode=True, ) + use_old_vardict = False @staticmethod def _first_non_zero(a): @@ -405,8 +406,8 @@ class Snap(StateModuleHelper): def state_present(self): - self.vars.meta('classic').set(output=True) - self.vars.meta('channel').set(output=True) + self.vars.set_meta('classic', output=True) + self.vars.set_meta('channel', output=True) actionable_refresh = [snap for snap in self.vars.name if self.vars.snap_status_map[snap] == Snap.CHANNEL_MISMATCH] if actionable_refresh: diff --git a/plugins/modules/snap_alias.py b/plugins/modules/snap_alias.py index 54448c6f3a..ba54a9e155 100644 --- a/plugins/modules/snap_alias.py +++ b/plugins/modules/snap_alias.py @@ -105,6 +105,7 @@ class SnapAlias(StateModuleHelper): ], supports_check_mode=True, ) + use_old_vardict = False def _aliases(self): n = self.vars.name diff --git a/plugins/modules/snmp_facts.py b/plugins/modules/snmp_facts.py index aecc08f325..d561f93f02 100644 --- a/plugins/modules/snmp_facts.py +++ b/plugins/modules/snmp_facts.py @@ -300,13 +300,19 @@ def main(): deps.validate(module) cmdGen = cmdgen.CommandGenerator() - transport_opts = dict((k, m_args[k]) for k in ('timeout', 'retries') if m_args[k] is not None) + transport_opts = { + k: m_args[k] + for k in ('timeout', 'retries') + if m_args[k] is not None + } # Verify that we receive a community when using snmp v2 if m_args['version'] in ("v2", "v2c"): if m_args['community'] is None: module.fail_json(msg='Community not set when using snmp version 2') + integrity_proto = None + privacy_proto = None if m_args['version'] == "v3": if m_args['username'] is None: module.fail_json(msg='Username not set when using snmp version 3') diff --git a/plugins/modules/sorcery.py b/plugins/modules/sorcery.py index 4fcf46a052..a525bd9ac8 100644 --- a/plugins/modules/sorcery.py +++ b/plugins/modules/sorcery.py @@ -280,7 +280,7 @@ def codex_list(module, skip_new=False): # return only specified grimoires unless requested to skip new if params['repository'] and not skip_new: - codex = dict((x, codex.get(x, NA)) for x in params['name']) + codex = {x: codex.get(x, NA) for x in params['name']} if not codex: module.fail_json(msg="no grimoires to operate on; add at least one") diff --git a/plugins/modules/ssh_config.py b/plugins/modules/ssh_config.py index e89e087b39..d974f45373 100644 --- a/plugins/modules/ssh_config.py +++ b/plugins/modules/ssh_config.py @@ -88,7 +88,8 @@ options: strict_host_key_checking: description: - Whether to strictly check the host key when doing connections to the remote host. - choices: [ 'yes', 'no', 'ask' ] + - The value V(accept-new) is supported since community.general 8.6.0. + choices: [ 'yes', 'no', 'ask', 'accept-new' ] type: str proxycommand: description: @@ -370,7 +371,7 @@ def main(): strict_host_key_checking=dict( type='str', default=None, - choices=['yes', 'no', 'ask'] + choices=['yes', 'no', 'ask', 'accept-new'], ), controlmaster=dict(type='str', default=None, choices=['yes', 'no', 'ask', 'auto', 'autoask']), controlpath=dict(type='str', default=None), diff --git a/plugins/modules/stackdriver.py b/plugins/modules/stackdriver.py deleted file mode 100644 index 35b2b0dc16..0000000000 --- a/plugins/modules/stackdriver.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- -# Copyright Ansible Project -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: stackdriver -short_description: Send code deploy and annotation events to stackdriver -description: - - Send code deploy and annotation events to Stackdriver -author: "Ben Whaley (@bwhaley)" -extends_documentation_fragment: - - community.general.attributes -attributes: - check_mode: - support: full - diff_mode: - support: none -options: - key: - type: str - description: - - API key. - required: true - event: - type: str - description: - - The type of event to send, either annotation or deploy - choices: ['annotation', 'deploy'] - required: true - revision_id: - type: str - description: - - The revision of the code that was deployed. Required for deploy events - deployed_by: - type: str - description: - - The person or robot responsible for deploying the code - default: "Ansible" - deployed_to: - type: str - description: - - "The environment code was deployed to. (ie: development, staging, production)" - repository: - type: str - description: - - The repository (or project) deployed - msg: - type: str - description: - - The contents of the annotation message, in plain text. Limited to 256 characters. Required for annotation. - annotated_by: - type: str - description: - - The person or robot who the annotation should be attributed to. - default: "Ansible" - level: - type: str - description: - - one of INFO/WARN/ERROR, defaults to INFO if not supplied. May affect display. - choices: ['INFO', 'WARN', 'ERROR'] - default: 'INFO' - instance_id: - type: str - description: - - id of an EC2 instance that this event should be attached to, which will limit the contexts where this event is shown - event_epoch: - type: str - description: - - "Unix timestamp of where the event should appear in the timeline, defaults to now. Be careful with this." -''' - -EXAMPLES = ''' -- name: Send a code deploy event to stackdriver - community.general.stackdriver: - key: AAAAAA - event: deploy - deployed_to: production - deployed_by: leeroyjenkins - repository: MyWebApp - revision_id: abcd123 - -- name: Send an annotation event to stackdriver - community.general.stackdriver: - key: AAAAAA - event: annotation - msg: Greetings from Ansible - annotated_by: leeroyjenkins - level: WARN - instance_id: i-abcd1234 -''' - -# =========================================== -# Stackdriver module specific support methods. -# - -import json -import traceback - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.common.text.converters import to_native -from ansible.module_utils.urls import fetch_url - - -def send_deploy_event(module, key, revision_id, deployed_by='Ansible', deployed_to=None, repository=None): - """Send a deploy event to Stackdriver""" - deploy_api = "https://event-gateway.stackdriver.com/v1/deployevent" - - params = {} - params['revision_id'] = revision_id - params['deployed_by'] = deployed_by - if deployed_to: - params['deployed_to'] = deployed_to - if repository: - params['repository'] = repository - - return do_send_request(module, deploy_api, params, key) - - -def send_annotation_event(module, key, msg, annotated_by='Ansible', level=None, instance_id=None, event_epoch=None): - """Send an annotation event to Stackdriver""" - annotation_api = "https://event-gateway.stackdriver.com/v1/annotationevent" - - params = {} - params['message'] = msg - if annotated_by: - params['annotated_by'] = annotated_by - if level: - params['level'] = level - if instance_id: - params['instance_id'] = instance_id - if event_epoch: - params['event_epoch'] = event_epoch - - return do_send_request(module, annotation_api, params, key) - - -def do_send_request(module, url, params, key): - data = json.dumps(params) - headers = { - 'Content-Type': 'application/json', - 'x-stackdriver-apikey': key - } - response, info = fetch_url(module, url, headers=headers, data=data, method='POST') - if info['status'] != 200: - module.fail_json(msg="Unable to send msg: %s" % info['msg']) - - -# =========================================== -# Module execution. -# - -def main(): - - module = AnsibleModule( - argument_spec=dict( # @TODO add types - key=dict(required=True, no_log=True), - event=dict(required=True, choices=['deploy', 'annotation']), - msg=dict(), - revision_id=dict(), - annotated_by=dict(default='Ansible'), - level=dict(default='INFO', choices=['INFO', 'WARN', 'ERROR']), - instance_id=dict(), - event_epoch=dict(), # @TODO int? - deployed_by=dict(default='Ansible'), - deployed_to=dict(), - repository=dict(), - ), - supports_check_mode=True - ) - - key = module.params["key"] - event = module.params["event"] - - # Annotation params - msg = module.params["msg"] - annotated_by = module.params["annotated_by"] - level = module.params["level"] - instance_id = module.params["instance_id"] - event_epoch = module.params["event_epoch"] - - # Deploy params - revision_id = module.params["revision_id"] - deployed_by = module.params["deployed_by"] - deployed_to = module.params["deployed_to"] - repository = module.params["repository"] - - ################################################################## - # deploy requires revision_id - # annotation requires msg - # We verify these manually - ################################################################## - - if event == 'deploy': - if not revision_id: - module.fail_json(msg="revision_id required for deploy events") - try: - send_deploy_event(module, key, revision_id, deployed_by, deployed_to, repository) - except Exception as e: - module.fail_json(msg="unable to sent deploy event: %s" % to_native(e), - exception=traceback.format_exc()) - - if event == 'annotation': - if not msg: - module.fail_json(msg="msg required for annotation events") - try: - send_annotation_event(module, key, msg, annotated_by, level, instance_id, event_epoch) - except Exception as e: - module.fail_json(msg="unable to sent annotation event: %s" % to_native(e), - exception=traceback.format_exc()) - - changed = True - module.exit_json(changed=changed, deployed_by=deployed_by) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/statusio_maintenance.py b/plugins/modules/statusio_maintenance.py index e6b34b7098..0a96d0fb41 100644 --- a/plugins/modules/statusio_maintenance.py +++ b/plugins/modules/statusio_maintenance.py @@ -188,6 +188,10 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.urls import open_url +from ansible_collections.community.general.plugins.module_utils.datetime import ( + now, +) + def get_api_auth_headers(api_id, api_key, url, statuspage): @@ -270,11 +274,11 @@ def get_date_time(start_date, start_time, minutes): except (NameError, ValueError): return 1, None, "Couldn't work out a valid date" else: - now = datetime.datetime.utcnow() - delta = now + datetime.timedelta(minutes=minutes) + now_t = now() + delta = now_t + datetime.timedelta(minutes=minutes) # start_date - returned_date.append(now.strftime("%m/%d/%Y")) - returned_date.append(now.strftime("%H:%M")) + returned_date.append(now_t.strftime("%m/%d/%Y")) + returned_date.append(now_t.strftime("%H:%M")) # end_date returned_date.append(delta.strftime("%m/%d/%Y")) returned_date.append(delta.strftime("%H:%M")) diff --git a/plugins/modules/svr4pkg.py b/plugins/modules/svr4pkg.py index db9902c770..56ded66e62 100644 --- a/plugins/modules/svr4pkg.py +++ b/plugins/modules/svr4pkg.py @@ -120,7 +120,7 @@ def package_installed(module, name, category): if category: cmd.append('-c') cmd.append(name) - rc, out, err = module.run_command(' '.join(cmd)) + rc, out, err = module.run_command(cmd) if rc == 0: return True else: diff --git a/plugins/modules/swdepot.py b/plugins/modules/swdepot.py index 28a8ce3145..9ba1b02b30 100644 --- a/plugins/modules/swdepot.py +++ b/plugins/modules/swdepot.py @@ -68,7 +68,6 @@ EXAMPLES = ''' import re from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves import shlex_quote def compare_package(version1, version2): @@ -94,13 +93,13 @@ def compare_package(version1, version2): def query_package(module, name, depot=None): """ Returns whether a package is installed or not and version. """ - cmd_list = '/usr/sbin/swlist -a revision -l product' + cmd_list = ['/usr/sbin/swlist', '-a', 'revision', '-l', 'product'] if depot: - rc, stdout, stderr = module.run_command("%s -s %s %s | grep %s" % (cmd_list, shlex_quote(depot), shlex_quote(name), shlex_quote(name)), - use_unsafe_shell=True) - else: - rc, stdout, stderr = module.run_command("%s %s | grep %s" % (cmd_list, shlex_quote(name), shlex_quote(name)), use_unsafe_shell=True) + cmd_list.extend(['-s', depot]) + cmd_list.append(name) + rc, stdout, stderr = module.run_command(cmd_list) if rc == 0: + stdout = ''.join(line for line in stdout.splitlines(True) if name in line) version = re.sub(r"\s\s+|\t", " ", stdout).strip().split()[1] else: version = None @@ -112,7 +111,7 @@ def remove_package(module, name): """ Uninstall package if installed. """ cmd_remove = '/usr/sbin/swremove' - rc, stdout, stderr = module.run_command("%s %s" % (cmd_remove, name)) + rc, stdout, stderr = module.run_command([cmd_remove, name]) if rc == 0: return rc, stdout @@ -123,8 +122,8 @@ def remove_package(module, name): def install_package(module, depot, name): """ Install package if not already installed """ - cmd_install = '/usr/sbin/swinstall -x mount_all_filesystems=false' - rc, stdout, stderr = module.run_command("%s -s %s %s" % (cmd_install, depot, name)) + cmd_install = ['/usr/sbin/swinstall', '-x', 'mount_all_filesystems=false'] + rc, stdout, stderr = module.run_command(cmd_install + ["-s", depot, name]) if rc == 0: return rc, stdout else: diff --git a/plugins/modules/timezone.py b/plugins/modules/timezone.py index e027290e86..cd823e6115 100644 --- a/plugins/modules/timezone.py +++ b/plugins/modules/timezone.py @@ -49,7 +49,8 @@ options: aliases: [ rtc ] choices: [ local, UTC ] notes: - - On SmartOS the C(sm-set-timezone) utility (part of the smtools package) is required to set the zone timezone + - On Ubuntu 24.04 the C(util-linux-extra) package is required to provide the C(hwclock) command. + - On SmartOS the C(sm-set-timezone) utility (part of the smtools package) is required to set the zone timezone. - On AIX only Olson/tz database timezones are usable (POSIX is not supported). An OS reboot is also required on AIX for the new timezone setting to take effect. Note that AIX 6.1+ is needed (OS level 61 or newer). @@ -75,6 +76,7 @@ diff: EXAMPLES = r''' - name: Set timezone to Asia/Tokyo + become: true community.general.timezone: name: Asia/Tokyo ''' diff --git a/plugins/modules/udm_user.py b/plugins/modules/udm_user.py index dcbf0ec85e..5a2e090497 100644 --- a/plugins/modules/udm_user.py +++ b/plugins/modules/udm_user.py @@ -20,6 +20,12 @@ description: - "This module allows to manage posix users on a univention corporate server (UCS). It uses the python API of the UCS to create a new object or edit it." +notes: + - This module does B(not) work with Python 3.13 or newer. It uses the deprecated L(crypt Python module, + https://docs.python.org/3.12/library/crypt.html) from the Python standard library, which was removed + from Python 3.13. +requirements: + - Python 3.12 or earlier extends_documentation_fragment: - community.general.attributes attributes: @@ -324,10 +330,10 @@ EXAMPLES = ''' RETURN = '''# ''' -import crypt from datetime import date, timedelta +import traceback -from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible_collections.community.general.plugins.module_utils.univention_umc import ( umc_module_for_add, umc_module_for_edit, @@ -335,6 +341,15 @@ from ansible_collections.community.general.plugins.module_utils.univention_umc i base_dn, ) +try: + import crypt +except ImportError: + HAS_CRYPT = False + CRYPT_IMPORT_ERROR = traceback.format_exc() +else: + HAS_CRYPT = True + CRYPT_IMPORT_ERROR = None + def main(): expiry = date.strftime(date.today() + timedelta(days=365), "%Y-%m-%d") @@ -451,6 +466,13 @@ def main(): ('state', 'present', ['firstname', 'lastname', 'password']) ]) ) + + if not HAS_CRYPT: + module.fail_json( + msg=missing_required_lib('crypt (part of Python 3.13 standard library)'), + exception=CRYPT_IMPORT_ERROR, + ) + username = module.params['username'] position = module.params['position'] ou = module.params['ou'] diff --git a/plugins/modules/ufw.py b/plugins/modules/ufw.py index 5d187793bd..7a90647979 100644 --- a/plugins/modules/ufw.py +++ b/plugins/modules/ufw.py @@ -446,7 +446,7 @@ def main(): params = module.params - commands = dict((key, params[key]) for key in command_keys if params[key]) + commands = {key: params[key] for key in command_keys if params[key]} # Ensure ufw is available ufw_bin = module.get_bin_path('ufw', True) diff --git a/plugins/modules/vmadm.py b/plugins/modules/vmadm.py index bfe6148375..923a902bcf 100644 --- a/plugins/modules/vmadm.py +++ b/plugins/modules/vmadm.py @@ -558,9 +558,11 @@ def create_payload(module, uuid): # Filter out the few options that are not valid VM properties. module_options = ['force', 'state'] - # @TODO make this a simple {} comprehension as soon as py2 is ditched - # @TODO {k: v for k, v in p.items() if k not in module_options} - vmdef = dict([(k, v) for k, v in module.params.items() if k not in module_options and v]) + vmdef = { + k: v + for k, v in module.params.items() + if k not in module_options and v + } try: vmdef_json = json.dumps(vmdef) diff --git a/plugins/modules/webfaction_app.py b/plugins/modules/webfaction_app.py deleted file mode 100644 index 81bfc8b686..0000000000 --- a/plugins/modules/webfaction_app.py +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright (c) 2015, Quentin Stafford-Fraser, with contributions gratefully acknowledged from: -# * Andy Baker -# * Federico Tarantini -# -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -# Create a Webfaction application using Ansible and the Webfaction API -# -# Valid application types can be found by looking here: -# https://docs.webfaction.com/xmlrpc-api/apps.html#application-types - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: webfaction_app -short_description: Add or remove applications on a Webfaction host -description: - - Add or remove applications on a Webfaction host. Further documentation at U(https://github.com/quentinsf/ansible-webfaction). -author: Quentin Stafford-Fraser (@quentinsf) -notes: - - > - You can run playbooks that use this on a local machine, or on a Webfaction host, or elsewhere, since the scripts use the remote webfaction API. - The location is not important. However, running them on multiple hosts I(simultaneously) is best avoided. If you do not specify C(localhost) as - your host, you may want to add C(serial=1) to the plays. - - See L(the webfaction API, https://docs.webfaction.com/xmlrpc-api/) for more info. - -extends_documentation_fragment: - - community.general.attributes - -attributes: - check_mode: - support: full - diff_mode: - support: none - -options: - name: - description: - - The name of the application - required: true - type: str - - state: - description: - - Whether the application should exist - choices: ['present', 'absent'] - default: "present" - type: str - - type: - description: - - The type of application to create. See the Webfaction docs at U(https://docs.webfaction.com/xmlrpc-api/apps.html) for a list. - required: true - type: str - - autostart: - description: - - Whether the app should restart with an C(autostart.cgi) script - type: bool - default: false - - extra_info: - description: - - Any extra parameters required by the app - default: '' - type: str - - port_open: - description: - - IF the port should be opened - type: bool - default: false - - login_name: - description: - - The webfaction account to use - required: true - type: str - - login_password: - description: - - The webfaction password to use - required: true - type: str - - machine: - description: - - The machine name to use (optional for accounts with only one machine) - type: str - -''' - -EXAMPLES = ''' - - name: Create a test app - community.general.webfaction_app: - name: "my_wsgi_app1" - state: present - type: mod_wsgi35-python27 - login_name: "{{webfaction_user}}" - login_password: "{{webfaction_passwd}}" - machine: "{{webfaction_machine}}" -''' - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves import xmlrpc_client - - -webfaction = xmlrpc_client.ServerProxy('https://api.webfaction.com/') - - -def main(): - - module = AnsibleModule( - argument_spec=dict( - name=dict(required=True), - state=dict(choices=['present', 'absent'], default='present'), - type=dict(required=True), - autostart=dict(type='bool', default=False), - extra_info=dict(default=""), - port_open=dict(type='bool', default=False), - login_name=dict(required=True), - login_password=dict(required=True, no_log=True), - machine=dict(), - ), - supports_check_mode=True - ) - app_name = module.params['name'] - app_type = module.params['type'] - app_state = module.params['state'] - - if module.params['machine']: - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'], - module.params['machine'] - ) - else: - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'] - ) - - app_list = webfaction.list_apps(session_id) - app_map = dict([(i['name'], i) for i in app_list]) - existing_app = app_map.get(app_name) - - result = {} - - # Here's where the real stuff happens - - if app_state == 'present': - - # Does an app with this name already exist? - if existing_app: - if existing_app['type'] != app_type: - module.fail_json(msg="App already exists with different type. Please fix by hand.") - - # If it exists with the right type, we don't change it - # Should check other parameters. - module.exit_json( - changed=False, - result=existing_app, - ) - - if not module.check_mode: - # If this isn't a dry run, create the app - result.update( - webfaction.create_app( - session_id, app_name, app_type, - module.boolean(module.params['autostart']), - module.params['extra_info'], - module.boolean(module.params['port_open']) - ) - ) - - elif app_state == 'absent': - - # If the app's already not there, nothing changed. - if not existing_app: - module.exit_json( - changed=False, - ) - - if not module.check_mode: - # If this isn't a dry run, delete the app - result.update( - webfaction.delete_app(session_id, app_name) - ) - - else: - module.fail_json(msg="Unknown state specified: {0}".format(app_state)) - - module.exit_json( - changed=True, - result=result - ) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/webfaction_db.py b/plugins/modules/webfaction_db.py deleted file mode 100644 index 5428de5b63..0000000000 --- a/plugins/modules/webfaction_db.py +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright (c) 2015, Quentin Stafford-Fraser, with contributions gratefully acknowledged from: -# * Andy Baker -# * Federico Tarantini -# -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -# Create a webfaction database using Ansible and the Webfaction API - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: webfaction_db -short_description: Add or remove a database on Webfaction -description: - - Add or remove a database on a Webfaction host. Further documentation at https://github.com/quentinsf/ansible-webfaction. -author: Quentin Stafford-Fraser (@quentinsf) -notes: - - > - You can run playbooks that use this on a local machine, or on a Webfaction host, or elsewhere, since the scripts use the remote webfaction API. - The location is not important. However, running them on multiple hosts I(simultaneously) is best avoided. If you do not specify C(localhost) as - your host, you may want to add C(serial=1) to the plays. - - See L(the webfaction API, https://docs.webfaction.com/xmlrpc-api/) for more info. -extends_documentation_fragment: - - community.general.attributes -attributes: - check_mode: - support: full - diff_mode: - support: none -options: - - name: - description: - - The name of the database - required: true - type: str - - state: - description: - - Whether the database should exist - choices: ['present', 'absent'] - default: "present" - type: str - - type: - description: - - The type of database to create. - required: true - choices: ['mysql', 'postgresql'] - type: str - - password: - description: - - The password for the new database user. - type: str - - login_name: - description: - - The webfaction account to use - required: true - type: str - - login_password: - description: - - The webfaction password to use - required: true - type: str - - machine: - description: - - The machine name to use (optional for accounts with only one machine) - type: str -''' - -EXAMPLES = ''' - # This will also create a default DB user with the same - # name as the database, and the specified password. - - - name: Create a database - community.general.webfaction_db: - name: "{{webfaction_user}}_db1" - password: mytestsql - type: mysql - login_name: "{{webfaction_user}}" - login_password: "{{webfaction_passwd}}" - machine: "{{webfaction_machine}}" - - # Note that, for symmetry's sake, deleting a database using - # 'state: absent' will also delete the matching user. - -''' - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves import xmlrpc_client - - -webfaction = xmlrpc_client.ServerProxy('https://api.webfaction.com/') - - -def main(): - - module = AnsibleModule( - argument_spec=dict( - name=dict(required=True), - state=dict(choices=['present', 'absent'], default='present'), - # You can specify an IP address or hostname. - type=dict(required=True, choices=['mysql', 'postgresql']), - password=dict(no_log=True), - login_name=dict(required=True), - login_password=dict(required=True, no_log=True), - machine=dict(), - ), - supports_check_mode=True - ) - db_name = module.params['name'] - db_state = module.params['state'] - db_type = module.params['type'] - db_passwd = module.params['password'] - - if module.params['machine']: - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'], - module.params['machine'] - ) - else: - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'] - ) - - db_list = webfaction.list_dbs(session_id) - db_map = dict([(i['name'], i) for i in db_list]) - existing_db = db_map.get(db_name) - - user_list = webfaction.list_db_users(session_id) - user_map = dict([(i['username'], i) for i in user_list]) - existing_user = user_map.get(db_name) - - result = {} - - # Here's where the real stuff happens - - if db_state == 'present': - - # Does a database with this name already exist? - if existing_db: - # Yes, but of a different type - fail - if existing_db['db_type'] != db_type: - module.fail_json(msg="Database already exists but is a different type. Please fix by hand.") - - # If it exists with the right type, we don't change anything. - module.exit_json( - changed=False, - ) - - if not module.check_mode: - # If this isn't a dry run, create the db - # and default user. - result.update( - webfaction.create_db( - session_id, db_name, db_type, db_passwd - ) - ) - - elif db_state == 'absent': - - # If this isn't a dry run... - if not module.check_mode: - - if not (existing_db or existing_user): - module.exit_json(changed=False,) - - if existing_db: - # Delete the db if it exists - result.update( - webfaction.delete_db(session_id, db_name, db_type) - ) - - if existing_user: - # Delete the default db user if it exists - result.update( - webfaction.delete_db_user(session_id, db_name, db_type) - ) - - else: - module.fail_json(msg="Unknown state specified: {0}".format(db_state)) - - module.exit_json( - changed=True, - result=result - ) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/webfaction_domain.py b/plugins/modules/webfaction_domain.py deleted file mode 100644 index 4c87a539a8..0000000000 --- a/plugins/modules/webfaction_domain.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright (c) 2015, Quentin Stafford-Fraser -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -# Create Webfaction domains and subdomains using Ansible and the Webfaction API - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: webfaction_domain -short_description: Add or remove domains and subdomains on Webfaction -description: - - Add or remove domains or subdomains on a Webfaction host. Further documentation at https://github.com/quentinsf/ansible-webfaction. -author: Quentin Stafford-Fraser (@quentinsf) -notes: - - If you are I(deleting) domains by using O(state=absent), then note that if you specify subdomains, just those particular subdomains will be deleted. - If you do not specify subdomains, the domain will be deleted. - - > - You can run playbooks that use this on a local machine, or on a Webfaction host, or elsewhere, since the scripts use the remote webfaction API. - The location is not important. However, running them on multiple hosts I(simultaneously) is best avoided. If you do not specify C(localhost) as - your host, you may want to add C(serial=1) to the plays. - - See L(the webfaction API, https://docs.webfaction.com/xmlrpc-api/) for more info. - -extends_documentation_fragment: - - community.general.attributes - -attributes: - check_mode: - support: full - diff_mode: - support: none - -options: - - name: - description: - - The name of the domain - required: true - type: str - - state: - description: - - Whether the domain should exist - choices: ['present', 'absent'] - default: "present" - type: str - - subdomains: - description: - - Any subdomains to create. - default: [] - type: list - elements: str - - login_name: - description: - - The webfaction account to use - required: true - type: str - - login_password: - description: - - The webfaction password to use - required: true - type: str -''' - -EXAMPLES = ''' - - name: Create a test domain - community.general.webfaction_domain: - name: mydomain.com - state: present - subdomains: - - www - - blog - login_name: "{{webfaction_user}}" - login_password: "{{webfaction_passwd}}" - - - name: Delete test domain and any subdomains - community.general.webfaction_domain: - name: mydomain.com - state: absent - login_name: "{{webfaction_user}}" - login_password: "{{webfaction_passwd}}" - -''' - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves import xmlrpc_client - - -webfaction = xmlrpc_client.ServerProxy('https://api.webfaction.com/') - - -def main(): - - module = AnsibleModule( - argument_spec=dict( - name=dict(required=True), - state=dict(choices=['present', 'absent'], default='present'), - subdomains=dict(default=[], type='list', elements='str'), - login_name=dict(required=True), - login_password=dict(required=True, no_log=True), - ), - supports_check_mode=True - ) - domain_name = module.params['name'] - domain_state = module.params['state'] - domain_subdomains = module.params['subdomains'] - - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'] - ) - - domain_list = webfaction.list_domains(session_id) - domain_map = dict([(i['domain'], i) for i in domain_list]) - existing_domain = domain_map.get(domain_name) - - result = {} - - # Here's where the real stuff happens - - if domain_state == 'present': - - # Does an app with this name already exist? - if existing_domain: - - if set(existing_domain['subdomains']) >= set(domain_subdomains): - # If it exists with the right subdomains, we don't change anything. - module.exit_json( - changed=False, - ) - - positional_args = [session_id, domain_name] + domain_subdomains - - if not module.check_mode: - # If this isn't a dry run, create the app - # print positional_args - result.update( - webfaction.create_domain( - *positional_args - ) - ) - - elif domain_state == 'absent': - - # If the app's already not there, nothing changed. - if not existing_domain: - module.exit_json( - changed=False, - ) - - positional_args = [session_id, domain_name] + domain_subdomains - - if not module.check_mode: - # If this isn't a dry run, delete the app - result.update( - webfaction.delete_domain(*positional_args) - ) - - else: - module.fail_json(msg="Unknown state specified: {0}".format(domain_state)) - - module.exit_json( - changed=True, - result=result - ) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/webfaction_mailbox.py b/plugins/modules/webfaction_mailbox.py deleted file mode 100644 index 119dfd283f..0000000000 --- a/plugins/modules/webfaction_mailbox.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright (c) 2015, Quentin Stafford-Fraser and Andy Baker -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -# Create webfaction mailbox using Ansible and the Webfaction API - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: webfaction_mailbox -short_description: Add or remove mailboxes on Webfaction -description: - - Add or remove mailboxes on a Webfaction account. Further documentation at https://github.com/quentinsf/ansible-webfaction. -author: Quentin Stafford-Fraser (@quentinsf) -notes: - - > - You can run playbooks that use this on a local machine, or on a Webfaction host, or elsewhere, since the scripts use the remote webfaction API. - The location is not important. However, running them on multiple hosts I(simultaneously) is best avoided. If you do not specify C(localhost) as - your host, you may want to add C(serial=1) to the plays. - - See L(the webfaction API, https://docs.webfaction.com/xmlrpc-api/) for more info. - -extends_documentation_fragment: - - community.general.attributes - -attributes: - check_mode: - support: full - diff_mode: - support: none - -options: - - mailbox_name: - description: - - The name of the mailbox - required: true - type: str - - mailbox_password: - description: - - The password for the mailbox - required: true - type: str - - state: - description: - - Whether the mailbox should exist - choices: ['present', 'absent'] - default: "present" - type: str - - login_name: - description: - - The webfaction account to use - required: true - type: str - - login_password: - description: - - The webfaction password to use - required: true - type: str -''' - -EXAMPLES = ''' - - name: Create a mailbox - community.general.webfaction_mailbox: - mailbox_name="mybox" - mailbox_password="myboxpw" - state=present - login_name={{webfaction_user}} - login_password={{webfaction_passwd}} -''' - - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves import xmlrpc_client - - -webfaction = xmlrpc_client.ServerProxy('https://api.webfaction.com/') - - -def main(): - - module = AnsibleModule( - argument_spec=dict( - mailbox_name=dict(required=True), - mailbox_password=dict(required=True, no_log=True), - state=dict(required=False, choices=['present', 'absent'], default='present'), - login_name=dict(required=True), - login_password=dict(required=True, no_log=True), - ), - supports_check_mode=True - ) - - mailbox_name = module.params['mailbox_name'] - site_state = module.params['state'] - - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'] - ) - - mailbox_list = [x['mailbox'] for x in webfaction.list_mailboxes(session_id)] - existing_mailbox = mailbox_name in mailbox_list - - result = {} - - # Here's where the real stuff happens - - if site_state == 'present': - - # Does a mailbox with this name already exist? - if existing_mailbox: - module.exit_json(changed=False,) - - positional_args = [session_id, mailbox_name] - - if not module.check_mode: - # If this isn't a dry run, create the mailbox - result.update(webfaction.create_mailbox(*positional_args)) - - elif site_state == 'absent': - - # If the mailbox is already not there, nothing changed. - if not existing_mailbox: - module.exit_json(changed=False) - - if not module.check_mode: - # If this isn't a dry run, delete the mailbox - result.update(webfaction.delete_mailbox(session_id, mailbox_name)) - - else: - module.fail_json(msg="Unknown state specified: {0}".format(site_state)) - - module.exit_json(changed=True, result=result) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/webfaction_site.py b/plugins/modules/webfaction_site.py deleted file mode 100644 index 7795c45fe8..0000000000 --- a/plugins/modules/webfaction_site.py +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright (c) 2015, Quentin Stafford-Fraser -# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) -# SPDX-License-Identifier: GPL-3.0-or-later - -# Create Webfaction website using Ansible and the Webfaction API - -from __future__ import absolute_import, division, print_function -__metaclass__ = type - - -DOCUMENTATION = ''' ---- - -deprecated: - removed_in: 9.0.0 - why: the endpoints this module relies on do not exist any more and do not resolve to IPs in DNS. - alternative: no known alternative at this point - -module: webfaction_site -short_description: Add or remove a website on a Webfaction host -description: - - Add or remove a website on a Webfaction host. Further documentation at https://github.com/quentinsf/ansible-webfaction. -author: Quentin Stafford-Fraser (@quentinsf) -notes: - - Sadly, you I(do) need to know your webfaction hostname for the C(host) parameter. But at least, unlike the API, you do not need to know the IP - address. You can use a DNS name. - - If a site of the same name exists in the account but on a different host, the operation will exit. - - > - You can run playbooks that use this on a local machine, or on a Webfaction host, or elsewhere, since the scripts use the remote webfaction API. - The location is not important. However, running them on multiple hosts I(simultaneously) is best avoided. If you do not specify C(localhost) as - your host, you may want to add C(serial=1) to the plays. - - See L(the webfaction API, https://docs.webfaction.com/xmlrpc-api/) for more info. - -extends_documentation_fragment: - - community.general.attributes - -attributes: - check_mode: - support: full - diff_mode: - support: none - -options: - - name: - description: - - The name of the website - required: true - type: str - - state: - description: - - Whether the website should exist - choices: ['present', 'absent'] - default: "present" - type: str - - host: - description: - - The webfaction host on which the site should be created. - required: true - type: str - - https: - description: - - Whether or not to use HTTPS - type: bool - default: false - - site_apps: - description: - - A mapping of URLs to apps - default: [] - type: list - elements: list - - subdomains: - description: - - A list of subdomains associated with this site. - default: [] - type: list - elements: str - - login_name: - description: - - The webfaction account to use - required: true - type: str - - login_password: - description: - - The webfaction password to use - required: true - type: str -''' - -EXAMPLES = ''' - - name: Create website - community.general.webfaction_site: - name: testsite1 - state: present - host: myhost.webfaction.com - subdomains: - - 'testsite1.my_domain.org' - site_apps: - - ['testapp1', '/'] - https: false - login_name: "{{webfaction_user}}" - login_password: "{{webfaction_passwd}}" -''' - -import socket - -from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves import xmlrpc_client - - -webfaction = xmlrpc_client.ServerProxy('https://api.webfaction.com/') - - -def main(): - - module = AnsibleModule( - argument_spec=dict( - name=dict(required=True), - state=dict(choices=['present', 'absent'], default='present'), - # You can specify an IP address or hostname. - host=dict(required=True), - https=dict(required=False, type='bool', default=False), - subdomains=dict(type='list', elements='str', default=[]), - site_apps=dict(type='list', elements='list', default=[]), - login_name=dict(required=True), - login_password=dict(required=True, no_log=True), - ), - supports_check_mode=True - ) - site_name = module.params['name'] - site_state = module.params['state'] - site_host = module.params['host'] - site_ip = socket.gethostbyname(site_host) - - session_id, account = webfaction.login( - module.params['login_name'], - module.params['login_password'] - ) - - site_list = webfaction.list_websites(session_id) - site_map = dict([(i['name'], i) for i in site_list]) - existing_site = site_map.get(site_name) - - result = {} - - # Here's where the real stuff happens - - if site_state == 'present': - - # Does a site with this name already exist? - if existing_site: - - # If yes, but it's on a different IP address, then fail. - # If we wanted to allow relocation, we could add a 'relocate=true' option - # which would get the existing IP address, delete the site there, and create it - # at the new address. A bit dangerous, perhaps, so for now we'll require manual - # deletion if it's on another host. - - if existing_site['ip'] != site_ip: - module.fail_json(msg="Website already exists with a different IP address. Please fix by hand.") - - # If it's on this host and the key parameters are the same, nothing needs to be done. - - if (existing_site['https'] == module.boolean(module.params['https'])) and \ - (set(existing_site['subdomains']) == set(module.params['subdomains'])) and \ - (dict(existing_site['website_apps']) == dict(module.params['site_apps'])): - module.exit_json( - changed=False - ) - - positional_args = [ - session_id, site_name, site_ip, - module.boolean(module.params['https']), - module.params['subdomains'], - ] - for a in module.params['site_apps']: - positional_args.append((a[0], a[1])) - - if not module.check_mode: - # If this isn't a dry run, create or modify the site - result.update( - webfaction.create_website( - *positional_args - ) if not existing_site else webfaction.update_website( - *positional_args - ) - ) - - elif site_state == 'absent': - - # If the site's already not there, nothing changed. - if not existing_site: - module.exit_json( - changed=False, - ) - - if not module.check_mode: - # If this isn't a dry run, delete the site - result.update( - webfaction.delete_website(session_id, site_name, site_ip) - ) - - else: - module.fail_json(msg="Unknown state specified: {0}".format(site_state)) - - module.exit_json( - changed=True, - result=result - ) - - -if __name__ == '__main__': - main() diff --git a/plugins/modules/xfconf.py b/plugins/modules/xfconf.py index 8ed44c675d..15943ae59d 100644 --- a/plugins/modules/xfconf.py +++ b/plugins/modules/xfconf.py @@ -187,6 +187,7 @@ class XFConfProperty(StateModuleHelper): required_together=[('value', 'value_type')], supports_check_mode=True, ) + use_old_vardict = False default_state = 'present' @@ -196,7 +197,7 @@ class XFConfProperty(StateModuleHelper): self.vars.channel) self.vars.set('previous_value', self._get()) self.vars.set('type', self.vars.value_type) - self.vars.meta('value').set(initial_value=self.vars.previous_value) + self.vars.set_meta('value', initial_value=self.vars.previous_value) def process_command_output(self, rc, out, err): if err.rstrip() == self.does_not: diff --git a/plugins/modules/xfconf_info.py b/plugins/modules/xfconf_info.py index 844ef3c111..3d56a70cb9 100644 --- a/plugins/modules/xfconf_info.py +++ b/plugins/modules/xfconf_info.py @@ -139,6 +139,7 @@ class XFConfInfo(ModuleHelper): ), supports_check_mode=True, ) + use_old_vardict = False def __init_module__(self): self.runner = xfconf_runner(self.module, check_rc=True) @@ -176,7 +177,7 @@ class XFConfInfo(ModuleHelper): proc = self._process_list_properties with self.runner.context('list_arg channel property', output_process=proc) as ctx: - result = ctx.run(**self.vars) + result = ctx.run(**self.vars.as_dict()) if not self.vars.list_arg and self.vars.is_array: output = "value_array" diff --git a/plugins/plugin_utils/ansible_type.py b/plugins/plugin_utils/ansible_type.py new file mode 100644 index 0000000000..ab78b78927 --- /dev/null +++ b/plugins/plugin_utils/ansible_type.py @@ -0,0 +1,47 @@ +# Copyright (c) 2024 Vladimir Botka +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.common._collections_compat import Mapping + + +def _atype(data, alias): + """ + Returns the name of the type class. + """ + + data_type = type(data).__name__ + return alias.get(data_type, data_type) + + +def _ansible_type(data, alias): + """ + Returns the Ansible data type. + """ + + if alias is None: + alias = {} + + if not isinstance(alias, Mapping): + msg = "The argument alias must be a dictionary. %s is %s" + raise AnsibleFilterError(msg % (alias, type(alias))) + + data_type = _atype(data, alias) + + if data_type == 'list' and len(data) > 0: + items = [_atype(i, alias) for i in data] + items_type = '|'.join(sorted(set(items))) + return ''.join((data_type, '[', items_type, ']')) + + if data_type == 'dict' and len(data) > 0: + keys = [_atype(i, alias) for i in data.keys()] + vals = [_atype(i, alias) for i in data.values()] + keys_type = '|'.join(sorted(set(keys))) + vals_type = '|'.join(sorted(set(vals))) + return ''.join((data_type, '[', keys_type, ', ', vals_type, ']')) + + return data_type diff --git a/plugins/plugin_utils/keys_filter.py b/plugins/plugin_utils/keys_filter.py new file mode 100644 index 0000000000..94234a15db --- /dev/null +++ b/plugins/plugin_utils/keys_filter.py @@ -0,0 +1,141 @@ +# Copyright (c) 2024 Vladimir Botka +# Copyright (c) 2024 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.six import string_types +from ansible.module_utils.common._collections_compat import Mapping, Sequence + + +def _keys_filter_params(data, matching_parameter): + """test parameters: + * data must be a list of dictionaries. All keys must be strings. + * matching_parameter is member of a list. + """ + + mp = matching_parameter + ml = ['equal', 'starts_with', 'ends_with', 'regex'] + + if not isinstance(data, Sequence): + msg = "First argument must be a list. %s is %s" + raise AnsibleFilterError(msg % (data, type(data))) + + for elem in data: + if not isinstance(elem, Mapping): + msg = "The data items must be dictionaries. %s is %s" + raise AnsibleFilterError(msg % (elem, type(elem))) + + for elem in data: + if not all(isinstance(item, string_types) for item in elem.keys()): + msg = "Top level keys must be strings. keys: %s" + raise AnsibleFilterError(msg % elem.keys()) + + if mp not in ml: + msg = "The matching_parameter must be one of %s. matching_parameter=%s" + raise AnsibleFilterError(msg % (ml, mp)) + + return + + +def _keys_filter_target_str(target, matching_parameter): + """ + Test: + * target is a non-empty string or list. + * If target is list all items are strings. + * target is a string or list with single string if matching_parameter=regex. + Convert target and return: + * tuple of unique target items, or + * tuple with single item, or + * compiled regex if matching_parameter=regex. + """ + + if not isinstance(target, Sequence): + msg = "The target must be a string or a list. target is %s." + raise AnsibleFilterError(msg % type(target)) + + if len(target) == 0: + msg = "The target can't be empty." + raise AnsibleFilterError(msg) + + if isinstance(target, list): + for elem in target: + if not isinstance(elem, string_types): + msg = "The target items must be strings. %s is %s" + raise AnsibleFilterError(msg % (elem, type(elem))) + + if matching_parameter == 'regex': + if isinstance(target, string_types): + r = target + else: + if len(target) > 1: + msg = "Single item is required in the target list if matching_parameter=regex." + raise AnsibleFilterError(msg) + else: + r = target[0] + try: + tt = re.compile(r) + except re.error: + msg = "The target must be a valid regex if matching_parameter=regex. target is %s" + raise AnsibleFilterError(msg % r) + elif isinstance(target, string_types): + tt = (target, ) + else: + tt = tuple(set(target)) + + return tt + + +def _keys_filter_target_dict(target, matching_parameter): + """ + Test: + * target is a list of dictionaries with attributes 'after' and 'before'. + * Attributes 'before' must be valid regex if matching_parameter=regex. + * Otherwise, the attributes 'before' must be strings. + Convert target and return: + * iterator that aggregates attributes 'before' and 'after', or + * iterator that aggregates compiled regex of attributes 'before' and 'after' if matching_parameter=regex. + """ + + if not isinstance(target, list): + msg = "The target must be a list. target is %s." + raise AnsibleFilterError(msg % (target, type(target))) + + if len(target) == 0: + msg = "The target can't be empty." + raise AnsibleFilterError(msg) + + for elem in target: + if not isinstance(elem, Mapping): + msg = "The target items must be dictionaries. %s is %s" + raise AnsibleFilterError(msg % (elem, type(elem))) + if not all(k in elem for k in ('before', 'after')): + msg = "All dictionaries in target must include attributes: after, before." + raise AnsibleFilterError(msg) + if not isinstance(elem['before'], string_types): + msg = "The attributes before must be strings. %s is %s" + raise AnsibleFilterError(msg % (elem['before'], type(elem['before']))) + if not isinstance(elem['after'], string_types): + msg = "The attributes after must be strings. %s is %s" + raise AnsibleFilterError(msg % (elem['after'], type(elem['after']))) + + before = [d['before'] for d in target] + after = [d['after'] for d in target] + + if matching_parameter == 'regex': + try: + tr = map(re.compile, before) + tz = list(zip(tr, after)) + except re.error: + msg = ("The attributes before must be valid regex if matching_parameter=regex." + " Not all items are valid regex in: %s") + raise AnsibleFilterError(msg % before) + else: + tz = list(zip(before, after)) + + return tz diff --git a/plugins/plugin_utils/unsafe.py b/plugins/plugin_utils/unsafe.py new file mode 100644 index 0000000000..4fdb8b3d51 --- /dev/null +++ b/plugins/plugin_utils/unsafe.py @@ -0,0 +1,41 @@ +# Copyright (c) 2023, Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re + +from ansible.module_utils.six import binary_type, text_type +from ansible.module_utils.common._collections_compat import Mapping, Set +from ansible.module_utils.common.collections import is_sequence +from ansible.utils.unsafe_proxy import ( + AnsibleUnsafe, + wrap_var as _make_unsafe, +) + +_RE_TEMPLATE_CHARS = re.compile(u'[{}]') +_RE_TEMPLATE_CHARS_BYTES = re.compile(b'[{}]') + + +def make_unsafe(value): + if value is None or isinstance(value, AnsibleUnsafe): + return value + + if isinstance(value, Mapping): + return {make_unsafe(key): make_unsafe(val) for key, val in value.items()} + elif isinstance(value, Set): + return set(make_unsafe(elt) for elt in value) + elif is_sequence(value): + return type(value)(make_unsafe(elt) for elt in value) + elif isinstance(value, binary_type): + if _RE_TEMPLATE_CHARS_BYTES.search(value): + value = _make_unsafe(value) + return value + elif isinstance(value, text_type): + if _RE_TEMPLATE_CHARS.search(value): + value = _make_unsafe(value) + return value + + return value diff --git a/plugins/test/ansible_type.py b/plugins/test/ansible_type.py new file mode 100644 index 0000000000..9ac5e138eb --- /dev/null +++ b/plugins/test/ansible_type.py @@ -0,0 +1,203 @@ +# Copyright (c) 2024 Vladimir Botka +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: ansible_type + short_description: Validate input type + version_added: "9.2.0" + author: Vladimir Botka (@vbotka) + description: This test validates input type. + options: + _input: + description: Input data. + type: raw + required: true + dtype: + description: A single data type, or a data types list to be validated. + type: raw + required: true + alias: + description: Data type aliases. + default: {} + type: dictionary +''' + +EXAMPLES = ''' + +# Substitution converts str to AnsibleUnicode +# ------------------------------------------- + +# String. AnsibleUnicode. +dtype: AnsibleUnicode +data: "abc" +result: '{{ data is community.general.ansible_type(dtype) }}' +# result => true + +# String. AnsibleUnicode alias str. +alias: {"AnsibleUnicode": "str"} +dtype: str +data: "abc" +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true + +# List. All items are AnsibleUnicode. +dtype: list[AnsibleUnicode] +data: ["a", "b", "c"] +result: '{{ data is community.general.ansible_type(dtype) }}' +# result => true + +# Dictionary. All keys are AnsibleUnicode. All values are AnsibleUnicode. +dtype: dict[AnsibleUnicode, AnsibleUnicode] +data: {"a": "foo", "b": "bar", "c": "baz"} +result: '{{ data is community.general.ansible_type(dtype) }}' +# result => true + +# No substitution and no alias. Type of strings is str +# ---------------------------------------------------- + +# String +dtype: str +result: '{{ "abc" is community.general.ansible_type(dtype) }}' +# result => true + +# Integer +dtype: int +result: '{{ 123 is community.general.ansible_type(dtype) }}' +# result => true + +# Float +dtype: float +result: '{{ 123.45 is community.general.ansible_type(dtype) }}' +# result => true + +# Boolean +dtype: bool +result: '{{ true is community.general.ansible_type(dtype) }}' +# result => true + +# List. All items are strings. +dtype: list[str] +result: '{{ ["a", "b", "c"] is community.general.ansible_type(dtype) }}' +# result => true + +# List of dictionaries. +dtype: list[dict] +result: '{{ [{"a": 1}, {"b": 2}] is community.general.ansible_type(dtype) }}' +# result => true + +# Dictionary. All keys are strings. All values are integers. +dtype: dict[str, int] +result: '{{ {"a": 1} is community.general.ansible_type(dtype) }}' +# result => true + +# Dictionary. All keys are strings. All values are integers. +dtype: dict[str, int] +result: '{{ {"a": 1, "b": 2} is community.general.ansible_type(dtype) }}' +# result => true + +# Type of strings is AnsibleUnicode or str +# ---------------------------------------- + +# Dictionary. The keys are integers or strings. All values are strings. +alias: {"AnsibleUnicode": "str"} +dtype: dict[int|str, str] +data: {1: 'a', 'b': 'b'} +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true + +# Dictionary. All keys are integers. All values are keys. +alias: {"AnsibleUnicode": "str"} +dtype: dict[int, str] +data: {1: 'a', 2: 'b'} +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true + +# Dictionary. All keys are strings. Multiple types values. +alias: {"AnsibleUnicode": "str"} +dtype: dict[str, bool|dict|float|int|list|str] +data: {'a': 1, 'b': 1.1, 'c': 'abc', 'd': True, 'e': ['x', 'y', 'z'], 'f': {'x': 1, 'y': 2}} +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true + +# List. Multiple types items. +alias: {"AnsibleUnicode": "str"} +dtype: list[bool|dict|float|int|list|str] +data: [1, 2, 1.1, 'abc', True, ['x', 'y', 'z'], {'x': 1, 'y': 2}] +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true + +# Option dtype is list +# -------------------- + +# AnsibleUnicode or str +dtype: ['AnsibleUnicode', 'str'] +data: abc +result: '{{ data is community.general.ansible_type(dtype) }}' +# result => true + +# float or int +dtype: ['float', 'int'] +data: 123 +result: '{{ data is community.general.ansible_type(dtype) }}' +# result => true + +# float or int +dtype: ['float', 'int'] +data: 123.45 +result: '{{ data is community.general.ansible_type(dtype) }}' +# result => true + +# Multiple alias +# -------------- + +# int alias number +alias: {"int": "number", "float": "number"} +dtype: number +data: 123 +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true + +# float alias number +alias: {"int": "number", "float": "number"} +dtype: number +data: 123.45 +result: '{{ data is community.general.ansible_type(dtype, alias) }}' +# result => true +''' + +RETURN = ''' + _value: + description: Whether the data type is valid. + type: bool +''' + +from ansible.errors import AnsibleFilterError +from ansible.module_utils.common._collections_compat import Sequence +from ansible_collections.community.general.plugins.plugin_utils.ansible_type import _ansible_type + + +def ansible_type(data, dtype, alias=None): + """Validates data type""" + + if not isinstance(dtype, Sequence): + msg = "The argument dtype must be a string or a list. dtype is %s." + raise AnsibleFilterError(msg % (dtype, type(dtype))) + + if isinstance(dtype, str): + data_types = [dtype] + else: + data_types = dtype + + return _ansible_type(data, alias) in data_types + + +class TestModule(object): + + def tests(self): + return { + 'ansible_type': ansible_type + } diff --git a/tests/integration/targets/ansible_galaxy_install/tasks/main.yml b/tests/integration/targets/ansible_galaxy_install/tasks/main.yml index 1ecd9980d4..5c4af6d167 100644 --- a/tests/integration/targets/ansible_galaxy_install/tasks/main.yml +++ b/tests/integration/targets/ansible_galaxy_install/tasks/main.yml @@ -4,10 +4,16 @@ # SPDX-License-Identifier: GPL-3.0-or-later ################################################### +- name: Make directory install_c + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/install_c" + state: directory + - name: Install collection netbox.netbox community.general.ansible_galaxy_install: type: collection name: netbox.netbox + dest: "{{ remote_tmp_dir }}/install_c" register: install_c0 - name: Assert collection netbox.netbox was installed @@ -20,6 +26,7 @@ community.general.ansible_galaxy_install: type: collection name: netbox.netbox + dest: "{{ remote_tmp_dir }}/install_c" register: install_c1 - name: Assert collection was not installed @@ -28,10 +35,16 @@ - install_c1 is not changed ################################################### +- name: Make directory install_r + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/install_r" + state: directory + - name: Install role ansistrano.deploy community.general.ansible_galaxy_install: type: role name: ansistrano.deploy + dest: "{{ remote_tmp_dir }}/install_r" register: install_r0 - name: Assert collection ansistrano.deploy was installed @@ -44,6 +57,7 @@ community.general.ansible_galaxy_install: type: role name: ansistrano.deploy + dest: "{{ remote_tmp_dir }}/install_r" register: install_r1 - name: Assert role was not installed @@ -86,3 +100,44 @@ assert: that: - install_rq1 is not changed + +################################################### +- name: Make directory upgrade_c + ansible.builtin.file: + path: "{{ remote_tmp_dir }}/upgrade_c" + state: directory + +- name: Install collection netbox.netbox 3.17.0 + community.general.ansible_galaxy_install: + type: collection + name: netbox.netbox:3.17.0 + dest: "{{ remote_tmp_dir }}/upgrade_c" + register: upgrade_c0 + +- name: Assert collection netbox.netbox was installed + assert: + that: + - upgrade_c0 is changed + - '"netbox.netbox" in upgrade_c0.new_collections' + +- name: Upgrade collection netbox.netbox + community.general.ansible_galaxy_install: + state: latest + type: collection + name: netbox.netbox + dest: "{{ remote_tmp_dir }}/upgrade_c" + register: upgrade_c1 + +- name: Upgrade collection netbox.netbox (again) + community.general.ansible_galaxy_install: + state: latest + type: collection + name: netbox.netbox + dest: "{{ remote_tmp_dir }}/upgrade_c" + register: upgrade_c2 + +- name: Assert collection was not installed + assert: + that: + - upgrade_c1 is changed + - upgrade_c2 is not changed diff --git a/tests/integration/targets/callback_timestamp/aliases b/tests/integration/targets/callback_timestamp/aliases new file mode 100644 index 0000000000..124adcfb8c --- /dev/null +++ b/tests/integration/targets/callback_timestamp/aliases @@ -0,0 +1,6 @@ +# Copyright (c) 2024, kurokobo +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or ) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/1 +needs/target/callback diff --git a/tests/integration/targets/callback_timestamp/tasks/main.yml b/tests/integration/targets/callback_timestamp/tasks/main.yml new file mode 100644 index 0000000000..5e0acc15f0 --- /dev/null +++ b/tests/integration/targets/callback_timestamp/tasks/main.yml @@ -0,0 +1,66 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2024, kurokobo +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Run tests + include_role: + name: callback + vars: + tests: + - name: Enable timestamp in the default length + environment: + ANSIBLE_NOCOLOR: 'true' + ANSIBLE_FORCE_COLOR: 'false' + ANSIBLE_STDOUT_CALLBACK: community.general.timestamp + ANSIBLE_CALLBACK_TIMESTAMP_FORMAT_STRING: "15:04:05" + playbook: | + - hosts: testhost + gather_facts: false + tasks: + - name: Sample task name + debug: + msg: sample debug msg + expected_output: [ + "", + "PLAY [testhost] ******************************************************* 15:04:05", + "", + "TASK [Sample task name] *********************************************** 15:04:05", + "ok: [testhost] => {", + " \"msg\": \"sample debug msg\"", + "}", + "", + "PLAY RECAP ************************************************************ 15:04:05", + "testhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 " + ] + + - name: Enable timestamp in the longer length + environment: + ANSIBLE_NOCOLOR: 'true' + ANSIBLE_FORCE_COLOR: 'false' + ANSIBLE_STDOUT_CALLBACK: community.general.timestamp + ANSIBLE_CALLBACK_TIMESTAMP_FORMAT_STRING: "2006-01-02T15:04:05" + playbook: | + - hosts: testhost + gather_facts: false + tasks: + - name: Sample task name + debug: + msg: sample debug msg + expected_output: [ + "", + "PLAY [testhost] ******************************************** 2006-01-02T15:04:05", + "", + "TASK [Sample task name] ************************************ 2006-01-02T15:04:05", + "ok: [testhost] => {", + " \"msg\": \"sample debug msg\"", + "}", + "", + "PLAY RECAP ************************************************* 2006-01-02T15:04:05", + "testhost : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 " + ] diff --git a/tests/integration/targets/cargo/tasks/main.yml b/tests/integration/targets/cargo/tasks/main.yml index 29f27c3fda..89f13960a6 100644 --- a/tests/integration/targets/cargo/tasks/main.yml +++ b/tests/integration/targets/cargo/tasks/main.yml @@ -16,6 +16,7 @@ - block: - import_tasks: test_general.yml - import_tasks: test_version.yml + - import_tasks: test_directory.yml environment: "{{ cargo_environment }}" when: has_cargo | default(false) - import_tasks: test_rustup_cargo.yml diff --git a/tests/integration/targets/cargo/tasks/test_directory.yml b/tests/integration/targets/cargo/tasks/test_directory.yml new file mode 100644 index 0000000000..f4275ede68 --- /dev/null +++ b/tests/integration/targets/cargo/tasks/test_directory.yml @@ -0,0 +1,122 @@ +--- +# Copyright (c) 2024 Colin Nolan +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create temp directory + tempfile: + state: directory + register: temp_directory + +- name: Test block + vars: + manifest_path: "{{ temp_directory.path }}/Cargo.toml" + package_name: hello-world-directory-test + block: + - name: Initialize package + ansible.builtin.command: + cmd: "cargo init --name {{ package_name }}" + args: + chdir: "{{ temp_directory.path }}" + + - name: Set package version (1.0.0) + ansible.builtin.lineinfile: + path: "{{ manifest_path }}" + regexp: '^version = ".*"$' + line: 'version = "1.0.0"' + + - name: Ensure package is uninstalled + community.general.cargo: + name: "{{ package_name }}" + state: absent + directory: "{{ temp_directory.path }}" + register: uninstall_absent + + - name: Install package + community.general.cargo: + name: "{{ package_name }}" + directory: "{{ temp_directory.path }}" + register: install_absent + + - name: Change package version (1.0.1) + ansible.builtin.lineinfile: + path: "{{ manifest_path }}" + regexp: '^version = ".*"$' + line: 'version = "1.0.1"' + + - name: Install package again (present) + community.general.cargo: + name: "{{ package_name }}" + state: present + directory: "{{ temp_directory.path }}" + register: install_present_state + + - name: Install package again (latest) + community.general.cargo: + name: "{{ package_name }}" + state: latest + directory: "{{ temp_directory.path }}" + register: install_latest_state + + - name: Change package version (2.0.0) + ansible.builtin.lineinfile: + path: "{{ manifest_path }}" + regexp: '^version = ".*"$' + line: 'version = "2.0.0"' + + - name: Install package with given version (matched) + community.general.cargo: + name: "{{ package_name }}" + version: "2.0.0" + directory: "{{ temp_directory.path }}" + register: install_given_version_matched + + - name: Install package with given version (unmatched) + community.general.cargo: + name: "{{ package_name }}" + version: "2.0.1" + directory: "{{ temp_directory.path }}" + register: install_given_version_unmatched + ignore_errors: true + + - name: Uninstall package + community.general.cargo: + name: "{{ package_name }}" + state: absent + directory: "{{ temp_directory.path }}" + register: uninstall_present + + - name: Install non-existant package + community.general.cargo: + name: "{{ package_name }}-non-existant" + state: present + directory: "{{ temp_directory.path }}" + register: install_non_existant + ignore_errors: true + + - name: Install non-existant source directory + community.general.cargo: + name: "{{ package_name }}" + state: present + directory: "{{ temp_directory.path }}/non-existant" + register: install_non_existant_source + ignore_errors: true + + always: + - name: Remove temp directory + file: + path: "{{ temp_directory.path }}" + state: absent + +- name: Check assertions + assert: + that: + - uninstall_absent is not changed + - install_absent is changed + - install_present_state is not changed + - install_latest_state is changed + - install_given_version_matched is changed + - install_given_version_unmatched is failed + - uninstall_present is changed + - install_non_existant is failed + - install_non_existant_source is failed diff --git a/tests/integration/targets/consul/tasks/consul_agent_check.yml b/tests/integration/targets/consul/tasks/consul_agent_check.yml new file mode 100644 index 0000000000..e1229c794f --- /dev/null +++ b/tests/integration/targets/consul/tasks/consul_agent_check.yml @@ -0,0 +1,114 @@ +--- +# Copyright (c) 2024, Michael Ilg (@Ilgmi) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a service + community.general.consul_agent_service: + name: nginx + service_port: 80 + address: localhost + tags: + - http + meta: + nginx_version: 1.25.3 + register: result + +- set_fact: + nginx_service: "{{result.service}}" + +- assert: + that: + - result is changed + - result.service.ID is defined + +- name: Add a check for service + community.general.consul_agent_check: + name: nginx_check + id: nginx_check + interval: 30s + http: http://localhost:80/morestatus + notes: "Nginx Check" + service_id: "{{ nginx_service.ID }}" + register: result + +- assert: + that: + - result is changed + - result.check is defined + - result.check.CheckID == 'nginx_check' + - result.check.ServiceID == 'nginx' + - result.check.Interval == '30s' + - result.check.Type == 'http' + - result.check.Notes == 'Nginx Check' + +- set_fact: + nginx_service_check: "{{ result.check }}" + +- name: Update check for service + community.general.consul_agent_check: + name: "{{ nginx_service_check.Name }}" + id: "{{ nginx_service_check.CheckID }}" + interval: 60s + http: http://localhost:80/morestatus + notes: "New Nginx Check" + service_id: "{{ nginx_service.ID }}" + register: result + +- assert: + that: + - result is changed + - result.check is defined + - result.check.CheckID == 'nginx_check' + - result.check.ServiceID == 'nginx' + - result.check.Interval == '1m0s' + - result.check.Type == 'http' + - result.check.Notes == 'New Nginx Check' + +- name: Remove check + community.general.consul_agent_check: + id: "{{ nginx_service_check.Name }}" + state: absent + service_id: "{{ nginx_service.ID }}" + register: result + +- assert: + that: + - result is changed + - result is not failed + - result.operation == 'remove' + +- name: Add a check + community.general.consul_agent_check: + name: check + id: check + interval: 30s + tcp: localhost:80 + notes: "check" + register: result + +- assert: + that: + - result is changed + - result.check is defined + +- name: Update a check + community.general.consul_agent_check: + name: check + id: check + interval: 60s + tcp: localhost:80 + notes: "check" + register: result + +- assert: + that: + - result is changed + - result.check is defined + - result.check.Interval == '1m0s' + +- name: Remove check + community.general.consul_agent_check: + id: check + state: absent + register: result \ No newline at end of file diff --git a/tests/integration/targets/consul/tasks/consul_agent_service.yml b/tests/integration/targets/consul/tasks/consul_agent_service.yml new file mode 100644 index 0000000000..95270f74b3 --- /dev/null +++ b/tests/integration/targets/consul/tasks/consul_agent_service.yml @@ -0,0 +1,89 @@ +--- +# Copyright (c) 2024, Michael Ilg (@Ilgmi) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create a service + community.general.consul_agent_service: + name: nginx + service_port: 80 + address: localhost + tags: + - http + meta: + nginx_version: 1.25.3 + register: result + +- set_fact: + nginx_service: "{{result.service}}" + +- assert: + that: + - result is changed + - result.service.ID is defined + - result.service.Service == 'nginx' + - result.service.Address == 'localhost' + - result.service.Port == 80 + - result.service.Tags[0] == 'http' + - result.service.Meta.nginx_version is defined + - result.service.Meta.nginx_version == '1.25.3' + - result.service.ContentHash is defined + +- name: Update service + community.general.consul_agent_service: + id: "{{ nginx_service.ID }}" + name: "{{ nginx_service.Service }}" + service_port: 8080 + address: 127.0.0.1 + tags: + - http + - new_tag + meta: + nginx_version: 1.0.0 + nginx: 1.25.3 + register: result +- assert: + that: + - result is changed + - result.service.ID is defined + - result.service.Service == 'nginx' + - result.service.Address == '127.0.0.1' + - result.service.Port == 8080 + - result.service.Tags[0] == 'http' + - result.service.Tags[1] == 'new_tag' + - result.service.Meta.nginx_version is defined + - result.service.Meta.nginx_version == '1.0.0' + - result.service.Meta.nginx is defined + - result.service.Meta.nginx == '1.25.3' + - result.service.ContentHash is defined + +- name: Update service not changed when updating again without changes + community.general.consul_agent_service: + id: "{{ nginx_service.ID }}" + name: "{{ nginx_service.Service }}" + service_port: 8080 + address: 127.0.0.1 + tags: + - http + - new_tag + meta: + nginx_version: 1.0.0 + nginx: 1.25.3 + register: result + +- assert: + that: + - result is not changed + - result.operation is not defined + +- name: Remove service + community.general.consul_agent_service: + id: "{{ nginx_service.ID }}" + state: absent + register: result + +- assert: + that: + - result is changed + - result is not failed + - result.operation == 'remove' \ No newline at end of file diff --git a/tests/integration/targets/consul/tasks/main.yml b/tests/integration/targets/consul/tasks/main.yml index 6fef2b9980..0ac58fc40e 100644 --- a/tests/integration/targets/consul/tasks/main.yml +++ b/tests/integration/targets/consul/tasks/main.yml @@ -97,6 +97,8 @@ - import_tasks: consul_token.yml - import_tasks: consul_auth_method.yml - import_tasks: consul_binding_rule.yml + - import_tasks: consul_agent_service.yml + - import_tasks: consul_agent_check.yml module_defaults: group/community.general.consul: token: "{{ consul_management_token }}" diff --git a/tests/integration/targets/cpanm/tasks/main.yml b/tests/integration/targets/cpanm/tasks/main.yml index c9adc1ca6b..89650154f2 100644 --- a/tests/integration/targets/cpanm/tasks/main.yml +++ b/tests/integration/targets/cpanm/tasks/main.yml @@ -6,7 +6,8 @@ - name: bail out for non-supported platforms meta: end_play when: - - (ansible_os_family != "RedHat" or ansible_distribution_major_version|int < 7) + - (ansible_os_family != "RedHat" or ansible_distribution_major_version|int < 8) # TODO: bump back to 7 + - (ansible_distribution != 'CentOS' or ansible_distribution_major_version|int < 8) # TODO: remove - ansible_os_family != "Debian" - name: install perl development package for Red Hat family diff --git a/tests/integration/targets/django_manage/aliases b/tests/integration/targets/django_manage/aliases index 9790549169..ae3c2623a0 100644 --- a/tests/integration/targets/django_manage/aliases +++ b/tests/integration/targets/django_manage/aliases @@ -18,3 +18,4 @@ skip/rhel9.0 skip/rhel9.1 skip/rhel9.2 skip/rhel9.3 +skip/rhel9.4 diff --git a/tests/integration/targets/django_manage/meta/main.yml b/tests/integration/targets/django_manage/meta/main.yml index 2fcd152f95..4a216308a2 100644 --- a/tests/integration/targets/django_manage/meta/main.yml +++ b/tests/integration/targets/django_manage/meta/main.yml @@ -5,3 +5,4 @@ dependencies: - setup_pkg_mgr + - setup_os_pkg_name diff --git a/tests/integration/targets/django_manage/tasks/main.yaml b/tests/integration/targets/django_manage/tasks/main.yaml index c07b538938..9c2d4789e3 100644 --- a/tests/integration/targets/django_manage/tasks/main.yaml +++ b/tests/integration/targets/django_manage/tasks/main.yaml @@ -9,17 +9,10 @@ suffix: .django_manage register: tmp_django_root -- name: Install virtualenv on CentOS 8 +- name: Install virtualenv package: - name: virtualenv + name: "{{ os_package_name.virtualenv }}" state: present - when: ansible_distribution == 'CentOS' and ansible_distribution_major_version == '8' - -- name: Install virtualenv on Arch Linux - pip: - name: virtualenv - state: present - when: ansible_os_family == 'Archlinux' - name: Install required library pip: @@ -43,6 +36,11 @@ chdir: "{{ tmp_django_root.path }}/startproj" cmd: "{{ tmp_django_root.path }}/venv/bin/django-admin startapp app1" +- name: Make manage.py executable + file: + path: "{{ tmp_django_root.path }}/startproj/test_django_manage_1/manage.py" + mode: "0755" + - name: Check community.general.django_manage: project_path: "{{ tmp_django_root.path }}/startproj/test_django_manage_1" diff --git a/tests/integration/targets/ejabberd_user/tasks/main.yml b/tests/integration/targets/ejabberd_user/tasks/main.yml index 33e07b785a..d7f1670d06 100644 --- a/tests/integration/targets/ejabberd_user/tasks/main.yml +++ b/tests/integration/targets/ejabberd_user/tasks/main.yml @@ -10,8 +10,11 @@ - name: Bail out if not supported ansible.builtin.meta: end_play - when: ansible_distribution in ('Alpine', 'openSUSE Leap', 'CentOS', 'Fedora') - + # TODO: remove Archlinux from the list + # TODO: remove Ubuntu 24.04 (noble) from the list + when: > + ansible_distribution in ('Alpine', 'openSUSE Leap', 'CentOS', 'Fedora', 'Archlinux') + or (ansible_distribution == 'Ubuntu' and ansible_distribution_release in ['noble']) - name: Remove ejabberd ansible.builtin.package: diff --git a/tests/integration/targets/filesystem/defaults/main.yml b/tests/integration/targets/filesystem/defaults/main.yml index ec446d2417..7ff30bcd54 100644 --- a/tests/integration/targets/filesystem/defaults/main.yml +++ b/tests/integration/targets/filesystem/defaults/main.yml @@ -15,6 +15,7 @@ tested_filesystems: # - 1.7.0 requires at least 30Mo # - 1.10.0 requires at least 38Mo # - resizefs asserts when initial fs is smaller than 60Mo and seems to require 1.10.0 + bcachefs: {fssize: 20, grow: true, new_uuid: null} ext4: {fssize: 10, grow: true, new_uuid: 'random'} ext4dev: {fssize: 10, grow: true, new_uuid: 'random'} ext3: {fssize: 10, grow: true, new_uuid: 'random'} diff --git a/tests/integration/targets/filesystem/tasks/main.yml b/tests/integration/targets/filesystem/tasks/main.yml index 0c15c21556..51361079ce 100644 --- a/tests/integration/targets/filesystem/tasks/main.yml +++ b/tests/integration/targets/filesystem/tasks/main.yml @@ -36,7 +36,7 @@ # Not available: btrfs, lvm, f2fs, ocfs2 # All BSD systems use swap fs, but only Linux needs mkswap # Supported: ext2/3/4 (e2fsprogs), xfs (xfsprogs), reiserfs (progsreiserfs), vfat - - 'not (ansible_system == "FreeBSD" and item.0.key in ["btrfs", "f2fs", "swap", "lvm", "ocfs2"])' + - 'not (ansible_system == "FreeBSD" and item.0.key in ["bcachefs", "btrfs", "f2fs", "swap", "lvm", "ocfs2"])' # Available on FreeBSD but not on testbed (util-linux conflicts with e2fsprogs): wipefs, mkfs.minix - 'not (ansible_system == "FreeBSD" and item.1 in ["overwrite_another_fs", "remove_fs"])' @@ -46,6 +46,10 @@ # Other limitations and corner cases + # bcachefs only on Alpine > 3.18 and Arch Linux for now + # other distributions have too old versions of bcachefs-tools and/or util-linux (blkid for UUID tests) + - 'ansible_distribution == "Alpine" and ansible_distribution_version is version("3.18", ">") and item.0.key == "bcachefs"' + - 'ansible_distribution == "Archlinux" and item.0.key == "bcachefs"' # f2fs-tools and reiserfs-utils packages not available with RHEL/CentOS on CI - 'not (ansible_distribution in ["CentOS", "RedHat"] and item.0.key in ["f2fs", "reiserfs"])' - 'not (ansible_os_family == "RedHat" and ansible_distribution_major_version is version("8", ">=") and diff --git a/tests/integration/targets/filesystem/tasks/setup.yml b/tests/integration/targets/filesystem/tasks/setup.yml index 97dafaeeec..77c028acaf 100644 --- a/tests/integration/targets/filesystem/tasks/setup.yml +++ b/tests/integration/targets/filesystem/tasks/setup.yml @@ -16,6 +16,16 @@ - e2fsprogs - xfsprogs +- name: "Install bcachefs tools" + ansible.builtin.package: + name: bcachefs-tools + state: present + when: + # bcachefs only on Alpine > 3.18 and Arch Linux for now + # other distributions have too old versions of bcachefs-tools and/or util-linux (blkid for UUID tests) + - ansible_distribution == "Alpine" and ansible_distribution_version is version("3.18", ">") + - ansible_distribution == "Archlinux" + - name: "Install btrfs progs" ansible.builtin.package: name: btrfs-progs diff --git a/tests/integration/targets/filter_from_ini/tasks/main.yml b/tests/integration/targets/filter_from_ini/tasks/main.yml index a2eca36a6e..abb92dfc55 100644 --- a/tests/integration/targets/filter_from_ini/tasks/main.yml +++ b/tests/integration/targets/filter_from_ini/tasks/main.yml @@ -12,15 +12,21 @@ another_section: connection: 'ssh' + interpolate_test: + interpolate_test_key: '%' + - name: 'Write INI file that reflects ini_test_dict to {{ ini_test_file }}' ansible.builtin.copy: dest: '{{ ini_test_file }}' content: | [section_name] - key_name=key value + key_name = key value [another_section] - connection=ssh + connection = ssh + + [interpolate_test] + interpolate_test_key = % - name: 'Slurp the test file: {{ ini_test_file }}' ansible.builtin.slurp: diff --git a/tests/integration/targets/filter_jc/aliases b/tests/integration/targets/filter_jc/aliases index 4e11515666..a39321e96d 100644 --- a/tests/integration/targets/filter_jc/aliases +++ b/tests/integration/targets/filter_jc/aliases @@ -6,3 +6,4 @@ azp/posix/2 skip/python2.7 # jc only supports python3.x skip/freebsd13.3 # FIXME - ruyaml compilation fails skip/freebsd14.0 # FIXME - ruyaml compilation fails +skip/freebsd14.1 # FIXME - ruyaml compilation fails diff --git a/tests/integration/targets/filter_keep_keys/aliases b/tests/integration/targets/filter_keep_keys/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/filter_keep_keys/tasks/main.yml b/tests/integration/targets/filter_keep_keys/tasks/main.yml new file mode 100644 index 0000000000..9c0674780e --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/tasks/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Tests + import_tasks: tests.yml diff --git a/tests/integration/targets/filter_keep_keys/tasks/tests.yml b/tests/integration/targets/filter_keep_keys/tasks/tests.yml new file mode 100644 index 0000000000..fa821702f0 --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/tasks/tests.yml @@ -0,0 +1,31 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Debug ansible_version + ansible.builtin.debug: + var: ansible_version + when: not quite_test | d(true) | bool + tags: ansible_version + +- name: Tests + ansible.builtin.assert: + that: + - (result | difference(i.0.result) | length) == 0 + success_msg: | + [OK] result: + {{ result | to_yaml }} + fail_msg: | + [ERR] result: + {{ result | to_yaml }} + quiet: "{{ quiet_test | d(true) | bool }}" + loop: "{{ tests | subelements('group') }}" + loop_control: + loop_var: i + label: "{{ i.1.mp | d('default') }}: {{ i.1.tt }}" + vars: + input: "{{ i.0.input }}" + target: "{{ i.1.tt }}" + mp: "{{ i.1.mp | d('default') }}" + result: "{{ lookup('template', i.0.template) }}" diff --git a/tests/integration/targets/filter_keep_keys/templates/default.j2 b/tests/integration/targets/filter_keep_keys/templates/default.j2 new file mode 100644 index 0000000000..cb1232f9ee --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/templates/default.j2 @@ -0,0 +1 @@ +{{ input | community.general.keep_keys(target=target) }} diff --git a/tests/integration/targets/filter_keep_keys/templates/default.j2.license b/tests/integration/targets/filter_keep_keys/templates/default.j2.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/templates/default.j2.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/filter_keep_keys/templates/mp.j2 b/tests/integration/targets/filter_keep_keys/templates/mp.j2 new file mode 100644 index 0000000000..753698d420 --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/templates/mp.j2 @@ -0,0 +1 @@ +{{ input | community.general.keep_keys(target=target, matching_parameter=mp) }} diff --git a/tests/integration/targets/filter_keep_keys/templates/mp.j2.license b/tests/integration/targets/filter_keep_keys/templates/mp.j2.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/templates/mp.j2.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/filter_keep_keys/vars/main/tests.yml b/tests/integration/targets/filter_keep_keys/vars/main/tests.yml new file mode 100644 index 0000000000..f1abceddda --- /dev/null +++ b/tests/integration/targets/filter_keep_keys/vars/main/tests.yml @@ -0,0 +1,40 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +tests: + - template: default.j2 + group: + - {tt: [k0_x0, k1_x1], d: 'By default, match keys that equal any of the items in the target.'} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {k0_x0: A0, k1_x1: B0} + - {k0_x0: A1, k1_x1: B1} + - template: mp.j2 + group: + - {mp: equal, tt: [k0_x0, k1_x1], d: Match keys that equal any of the items in the target.} + - {mp: starts_with, tt: [k0, k1], d: Match keys that start with any of the items in the target.} + - {mp: ends_with, tt: [x0, x1], d: Match keys that end with any of the items in target.} + - {mp: regex, tt: ['^.*[01]_x.*$'], d: Match keys by the regex.} + - {mp: regex, tt: '^.*[01]_x.*$', d: Match keys by the regex.} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {k0_x0: A0, k1_x1: B0} + - {k0_x0: A1, k1_x1: B1} + - template: mp.j2 + group: + - {mp: equal, tt: k0_x0, d: Match keys that equal the target.} + - {mp: starts_with, tt: k0, d: Match keys that start with the target.} + - {mp: ends_with, tt: x0, d: Match keys that end with the target.} + - {mp: regex, tt: '^.*0_x.*$', d: Match keys by the regex.} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {k0_x0: A0} + - {k0_x0: A1} diff --git a/tests/integration/targets/filter_remove_keys/aliases b/tests/integration/targets/filter_remove_keys/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/filter_remove_keys/tasks/main.yml b/tests/integration/targets/filter_remove_keys/tasks/main.yml new file mode 100644 index 0000000000..9c0674780e --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/tasks/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Tests + import_tasks: tests.yml diff --git a/tests/integration/targets/filter_remove_keys/tasks/tests.yml b/tests/integration/targets/filter_remove_keys/tasks/tests.yml new file mode 100644 index 0000000000..fa821702f0 --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/tasks/tests.yml @@ -0,0 +1,31 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Debug ansible_version + ansible.builtin.debug: + var: ansible_version + when: not quite_test | d(true) | bool + tags: ansible_version + +- name: Tests + ansible.builtin.assert: + that: + - (result | difference(i.0.result) | length) == 0 + success_msg: | + [OK] result: + {{ result | to_yaml }} + fail_msg: | + [ERR] result: + {{ result | to_yaml }} + quiet: "{{ quiet_test | d(true) | bool }}" + loop: "{{ tests | subelements('group') }}" + loop_control: + loop_var: i + label: "{{ i.1.mp | d('default') }}: {{ i.1.tt }}" + vars: + input: "{{ i.0.input }}" + target: "{{ i.1.tt }}" + mp: "{{ i.1.mp | d('default') }}" + result: "{{ lookup('template', i.0.template) }}" diff --git a/tests/integration/targets/filter_remove_keys/templates/default.j2 b/tests/integration/targets/filter_remove_keys/templates/default.j2 new file mode 100644 index 0000000000..0dbc26323f --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/templates/default.j2 @@ -0,0 +1 @@ +{{ input | community.general.remove_keys(target=target) }} diff --git a/tests/integration/targets/filter_remove_keys/templates/default.j2.license b/tests/integration/targets/filter_remove_keys/templates/default.j2.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/templates/default.j2.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/filter_remove_keys/templates/mp.j2 b/tests/integration/targets/filter_remove_keys/templates/mp.j2 new file mode 100644 index 0000000000..5caa27a9b8 --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/templates/mp.j2 @@ -0,0 +1 @@ +{{ input | community.general.remove_keys(target=target, matching_parameter=mp) }} diff --git a/tests/integration/targets/filter_remove_keys/templates/mp.j2.license b/tests/integration/targets/filter_remove_keys/templates/mp.j2.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/templates/mp.j2.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/filter_remove_keys/vars/main/tests.yml b/tests/integration/targets/filter_remove_keys/vars/main/tests.yml new file mode 100644 index 0000000000..a4767ea799 --- /dev/null +++ b/tests/integration/targets/filter_remove_keys/vars/main/tests.yml @@ -0,0 +1,40 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +tests: + - template: default.j2 + group: + - {tt: [k0_x0, k1_x1], d: 'By default, match keys that equal any of the items in the target.'} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {k2_x2: [C0], k3_x3: foo} + - {k2_x2: [C1], k3_x3: bar} + - template: mp.j2 + group: + - {mp: equal, tt: [k0_x0, k1_x1], d: Match keys that equal any of the items in the target.} + - {mp: starts_with, tt: [k0, k1], d: Match keys that start with any of the items in the target.} + - {mp: ends_with, tt: [x0, x1], d: Match keys that end with any of the items in target.} + - {mp: regex, tt: ['^.*[01]_x.*$'], d: Match keys by the regex.} + - {mp: regex, tt: '^.*[01]_x.*$', d: Match keys by the regex.} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {k2_x2: [C0], k3_x3: foo} + - {k2_x2: [C1], k3_x3: bar} + - template: mp.j2 + group: + - {mp: equal, tt: k0_x0, d: Match keys that equal the target.} + - {mp: starts_with, tt: k0, d: Match keys that start with the target.} + - {mp: ends_with, tt: x0, d: Match keys that end with the target.} + - {mp: regex, tt: '^.*0_x.*$', d: Match keys by the regex.} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k1_x1: B1, k2_x2: [C1], k3_x3: bar} diff --git a/tests/integration/targets/filter_replace_keys/aliases b/tests/integration/targets/filter_replace_keys/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/filter_replace_keys/tasks/main.yml b/tests/integration/targets/filter_replace_keys/tasks/main.yml new file mode 100644 index 0000000000..9c0674780e --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/tasks/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Tests + import_tasks: tests.yml diff --git a/tests/integration/targets/filter_replace_keys/tasks/tests.yml b/tests/integration/targets/filter_replace_keys/tasks/tests.yml new file mode 100644 index 0000000000..fa821702f0 --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/tasks/tests.yml @@ -0,0 +1,31 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Debug ansible_version + ansible.builtin.debug: + var: ansible_version + when: not quite_test | d(true) | bool + tags: ansible_version + +- name: Tests + ansible.builtin.assert: + that: + - (result | difference(i.0.result) | length) == 0 + success_msg: | + [OK] result: + {{ result | to_yaml }} + fail_msg: | + [ERR] result: + {{ result | to_yaml }} + quiet: "{{ quiet_test | d(true) | bool }}" + loop: "{{ tests | subelements('group') }}" + loop_control: + loop_var: i + label: "{{ i.1.mp | d('default') }}: {{ i.1.tt }}" + vars: + input: "{{ i.0.input }}" + target: "{{ i.1.tt }}" + mp: "{{ i.1.mp | d('default') }}" + result: "{{ lookup('template', i.0.template) }}" diff --git a/tests/integration/targets/filter_replace_keys/templates/default.j2 b/tests/integration/targets/filter_replace_keys/templates/default.j2 new file mode 100644 index 0000000000..6ba66cd690 --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/templates/default.j2 @@ -0,0 +1 @@ +{{ input | community.general.replace_keys(target=target) }} diff --git a/tests/integration/targets/filter_replace_keys/templates/default.j2.license b/tests/integration/targets/filter_replace_keys/templates/default.j2.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/templates/default.j2.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/filter_replace_keys/templates/mp.j2 b/tests/integration/targets/filter_replace_keys/templates/mp.j2 new file mode 100644 index 0000000000..70c5009d91 --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/templates/mp.j2 @@ -0,0 +1 @@ +{{ input | community.general.replace_keys(target=target, matching_parameter=mp) }} diff --git a/tests/integration/targets/filter_replace_keys/templates/mp.j2.license b/tests/integration/targets/filter_replace_keys/templates/mp.j2.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/templates/mp.j2.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/filter_replace_keys/vars/main/tests.yml b/tests/integration/targets/filter_replace_keys/vars/main/tests.yml new file mode 100644 index 0000000000..ca906a770b --- /dev/null +++ b/tests/integration/targets/filter_replace_keys/vars/main/tests.yml @@ -0,0 +1,71 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +tests: + - template: default.j2 + group: + - d: By default, match keys that equal any of the attributes before. + tt: + - {before: k0_x0, after: a0} + - {before: k1_x1, after: a1} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {a0: A0, a1: B0, k2_x2: [C0], k3_x3: foo} + - {a0: A1, a1: B1, k2_x2: [C1], k3_x3: bar} + - template: mp.j2 + group: + - d: Replace keys that starts with any of the attributes before. + mp: starts_with + tt: + - {before: k0, after: a0} + - {before: k1, after: a1} + - d: Replace keys that ends with any of the attributes before. + mp: ends_with + tt: + - {before: x0, after: a0} + - {before: x1, after: a1} + - d: Replace keys that match any regex of the attributes before. + mp: regex + tt: + - {before: "^.*0_x.*$", after: a0} + - {before: "^.*1_x.*$", after: a1} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - {a0: A0, a1: B0, k2_x2: [C0], k3_x3: foo} + - {a0: A1, a1: B1, k2_x2: [C1], k3_x3: bar} + - template: mp.j2 + group: + - d: If more keys match the same attribute before the last one will be used. + mp: regex + tt: + - {before: "^.*_x.*$", after: X} + - d: If there are items with equal attribute before the first one will be used. + mp: regex + tt: + - {before: "^.*_x.*$", after: X} + - {before: "^.*_x.*$", after: Y} + input: + - {k0_x0: A0, k1_x1: B0, k2_x2: [C0], k3_x3: foo} + - {k0_x0: A1, k1_x1: B1, k2_x2: [C1], k3_x3: bar} + result: + - X: foo + - X: bar + - template: mp.j2 + group: + - d: If there are more matches for a key the first one will be used. + mp: starts_with + tt: + - {before: a, after: X} + - {before: aa, after: Y} + input: + - {aaa1: A, bbb1: B, ccc1: C} + - {aaa2: D, bbb2: E, ccc2: F} + result: + - {X: A, bbb1: B, ccc1: C} + - {X: D, bbb2: E, ccc2: F} diff --git a/tests/integration/targets/filter_reveal_ansible_type/aliases b/tests/integration/targets/filter_reveal_ansible_type/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/filter_reveal_ansible_type/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/filter_reveal_ansible_type/tasks/main.yml b/tests/integration/targets/filter_reveal_ansible_type/tasks/main.yml new file mode 100644 index 0000000000..c890c11901 --- /dev/null +++ b/tests/integration/targets/filter_reveal_ansible_type/tasks/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Integration tests + import_tasks: tasks.yml diff --git a/tests/integration/targets/filter_reveal_ansible_type/tasks/tasks.yml b/tests/integration/targets/filter_reveal_ansible_type/tasks/tasks.yml new file mode 100644 index 0000000000..37d3abcb71 --- /dev/null +++ b/tests/integration/targets/filter_reveal_ansible_type/tasks/tasks.yml @@ -0,0 +1,185 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Substitution converts str to AnsibleUnicode +# ------------------------------------------- + +- name: String. AnsibleUnicode. + assert: + that: result == dtype + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: "abc" + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: 'AnsibleUnicode' + +- name: String. AnsibleUnicode alias str. + assert: + that: result == dtype + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: "abc" + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: 'str' + +- name: List. All items are AnsibleUnicode. + assert: + that: result == dtype + success_msg: '["a", "b", "c"] is {{ dtype }}' + fail_msg: '["a", "b", "c"] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: ["a", "b", "c"] + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: 'list[AnsibleUnicode]' + +- name: Dictionary. All keys are AnsibleUnicode. All values are AnsibleUnicode. + assert: + that: result == dtype + success_msg: '{"a": "foo", "b": "bar", "c": "baz"} is {{ dtype }}' + fail_msg: '{"a": "foo", "b": "bar", "c": "baz"} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: {"a": "foo", "b": "bar", "c": "baz"} + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: 'dict[AnsibleUnicode, AnsibleUnicode]' + +# No substitution and no alias. Type of strings is str +# ---------------------------------------------------- + +- name: String + assert: + that: result == dtype + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ "abc" | community.general.reveal_ansible_type }}' + dtype: str + +- name: Integer + assert: + that: result == dtype + success_msg: '123 is {{ dtype }}' + fail_msg: '123 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ 123 | community.general.reveal_ansible_type }}' + dtype: int + +- name: Float + assert: + that: result == dtype + success_msg: '123.45 is {{ dtype }}' + fail_msg: '123.45 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ 123.45 | community.general.reveal_ansible_type }}' + dtype: float + +- name: Boolean + assert: + that: result == dtype + success_msg: 'true is {{ dtype }}' + fail_msg: 'true is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ true | community.general.reveal_ansible_type }}' + dtype: bool + +- name: List. All items are strings. + assert: + that: result == dtype + success_msg: '["a", "b", "c"] is {{ dtype }}' + fail_msg: '["a", "b", "c"] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ ["a", "b", "c"] | community.general.reveal_ansible_type }}' + dtype: list[str] + +- name: List of dictionaries. + assert: + that: result == dtype + success_msg: '[{"a": 1}, {"b": 2}] is {{ dtype }}' + fail_msg: '[{"a": 1}, {"b": 2}] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ [{"a": 1}, {"b": 2}] | community.general.reveal_ansible_type }}' + dtype: list[dict] + +- name: Dictionary. All keys are strings. All values are integers. + assert: + that: result == dtype + success_msg: '{"a": 1} is {{ dtype }}' + fail_msg: '{"a": 1} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ {"a": 1} | community.general.reveal_ansible_type }}' + dtype: dict[str, int] + +- name: Dictionary. All keys are strings. All values are integers. + assert: + that: result == dtype + success_msg: '{"a": 1, "b": 2} is {{ dtype }}' + fail_msg: '{"a": 1, "b": 2} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ {"a": 1, "b": 2} | community.general.reveal_ansible_type }}' + dtype: dict[str, int] + +# Type of strings is AnsibleUnicode or str +# ---------------------------------------- + +- name: Dictionary. The keys are integers or strings. All values are strings. + assert: + that: result == dtype + success_msg: 'data is {{ dtype }}' + fail_msg: 'data is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: {1: 'a', 'b': 'b'} + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: dict[int|str, str] + +- name: Dictionary. All keys are integers. All values are keys. + assert: + that: result == dtype + success_msg: 'data is {{ dtype }}' + fail_msg: 'data is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: {1: 'a', 2: 'b'} + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: dict[int, str] + +- name: Dictionary. All keys are strings. Multiple types values. + assert: + that: result == dtype + success_msg: 'data is {{ dtype }}' + fail_msg: 'data is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: {'a': 1, 'b': 1.1, 'c': 'abc', 'd': True, 'e': ['x', 'y', 'z'], 'f': {'x': 1, 'y': 2}} + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: dict[str, bool|dict|float|int|list|str] + +- name: List. Multiple types items. + assert: + that: result == dtype + success_msg: 'data is {{ dtype }}' + fail_msg: 'data is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: [1, 2, 1.1, 'abc', True, ['x', 'y', 'z'], {'x': 1, 'y': 2}] + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: list[bool|dict|float|int|list|str] diff --git a/tests/integration/targets/filter_to_ini/tasks/main.yml b/tests/integration/targets/filter_to_ini/tasks/main.yml index 877d4471d8..e16aa98a5a 100644 --- a/tests/integration/targets/filter_to_ini/tasks/main.yml +++ b/tests/integration/targets/filter_to_ini/tasks/main.yml @@ -16,6 +16,9 @@ another_section: connection: 'ssh' + interpolate_test: + interpolate_test_key: '%' + - name: 'Write INI file manually to {{ ini_test_file }}' ansible.builtin.copy: dest: '{{ ini_test_file }}' @@ -26,6 +29,9 @@ [another_section] connection = ssh + [interpolate_test] + interpolate_test_key = % + - name: 'Slurp the manually created test file: {{ ini_test_file }}' ansible.builtin.slurp: src: '{{ ini_test_file }}' diff --git a/tests/integration/targets/flatpak/tasks/check_mode.yml b/tests/integration/targets/flatpak/tasks/check_mode.yml index 9f52dc1229..b4538200ff 100644 --- a/tests/integration/targets/flatpak/tasks/check_mode.yml +++ b/tests/integration/targets/flatpak/tasks/check_mode.yml @@ -52,6 +52,38 @@ - removal_result is not changed msg: "Removing an absent flatpak shall mark module execution as not changed" +# state=latest on absent flatpak + +- name: Test state=latest of absent flatpak (check mode) + flatpak: + name: com.dummy.App1 + remote: dummy-remote + state: latest + register: latest_result + check_mode: true + +- name: Verify state=latest of absent flatpak test result (check mode) + assert: + that: + - latest_result is changed + msg: "state=latest an absent flatpak shall mark module execution as changed" + +- name: Test non-existent idempotency of state=latest of absent flatpak (check mode) + flatpak: + name: com.dummy.App1 + remote: dummy-remote + state: latest + register: double_latest_result + check_mode: true + +- name: Verify non-existent idempotency of state=latest of absent flatpak test result (check mode) + assert: + that: + - double_latest_result is changed + msg: | + state=latest an absent flatpak a second time shall still mark module execution + as changed in check mode + # state=present with url on absent flatpak - name: Test addition of absent flatpak with url (check mode) @@ -101,6 +133,40 @@ - url_removal_result is not changed msg: "Removing an absent flatpak shall mark module execution as not changed" +# state=latest with url on absent flatpak + +- name: Test state=latest of absent flatpak with url (check mode) + flatpak: + name: http://127.0.0.1:8000/repo/com.dummy.App1.flatpakref + remote: dummy-remote + state: latest + register: url_latest_result + check_mode: true + +- name: Verify state=latest of absent flatpak with url test result (check mode) + assert: + that: + - url_latest_result is changed + msg: "state=latest an absent flatpak from URL shall mark module execution as changed" + +- name: Test non-existent idempotency of state=latest of absent flatpak with url (check mode) + flatpak: + name: http://127.0.0.1:8000/repo/com.dummy.App1.flatpakref + remote: dummy-remote + state: latest + register: double_url_latest_result + check_mode: true + +- name: > + Verify non-existent idempotency of additionof state=latest flatpak with url test + result (check mode) + assert: + that: + - double_url_latest_result is changed + msg: | + state=latest an absent flatpak from URL a second time shall still mark module execution + as changed in check mode + # - Tests with present flatpak ------------------------------------------------- # state=present on present flatpak @@ -149,6 +215,22 @@ Removing a present flatpak a second time shall still mark module execution as changed in check mode +# state=latest on present flatpak + +- name: Test state=latest of present flatpak (check mode) + flatpak: + name: com.dummy.App2 + remote: dummy-remote + state: latest + register: latest_present_result + check_mode: true + +- name: Verify latest test result of present flatpak (check mode) + assert: + that: + - latest_present_result is changed + msg: "state=latest an present flatpak shall mark module execution as changed" + # state=present with url on present flatpak - name: Test addition with url of present flatpak (check mode) @@ -195,3 +277,19 @@ that: - double_url_removal_present_result is changed msg: Removing an absent flatpak a second time shall still mark module execution as changed + +# state=latest with url on present flatpak + +- name: Test state=latest with url of present flatpak (check mode) + flatpak: + name: http://127.0.0.1:8000/repo/com.dummy.App2.flatpakref + remote: dummy-remote + state: latest + register: url_latest_present_result + check_mode: true + +- name: Verify state=latest with url of present flatpak test result (check mode) + assert: + that: + - url_latest_present_result is changed + msg: "state=latest a present flatpak from URL shall mark module execution as changed" diff --git a/tests/integration/targets/flatpak/tasks/test.yml b/tests/integration/targets/flatpak/tasks/test.yml index 29c4efbe95..658f7b1168 100644 --- a/tests/integration/targets/flatpak/tasks/test.yml +++ b/tests/integration/targets/flatpak/tasks/test.yml @@ -65,6 +65,45 @@ - double_removal_result is not changed msg: "state=absent shall not do anything when flatpak is not present" +# state=latest + +- name: Test state=latest - {{ method }} + flatpak: + name: com.dummy.App1 + remote: dummy-remote + state: present + method: "{{ method }}" + no_dependencies: true + register: latest_result + +- name: Verify state=latest test result - {{ method }} + assert: + that: + - latest_result is changed + msg: "state=latest shall add flatpak when absent" + +- name: Test idempotency of state=latest - {{ method }} + flatpak: + name: com.dummy.App1 + remote: dummy-remote + state: present + method: "{{ method }}" + no_dependencies: true + register: double_latest_result + +- name: Verify idempotency of state=latest test result - {{ method }} + assert: + that: + - double_latest_result is not changed + msg: "state=latest shall not do anything when flatpak is already present" + +- name: Cleanup after state=present test - {{ method }} + flatpak: + name: com.dummy.App1 + state: absent + method: "{{ method }}" + no_dependencies: true + # state=present with url as name - name: Test addition with url - {{ method }} @@ -152,6 +191,45 @@ method: "{{ method }}" no_dependencies: true +# state=latest with url as name + +- name: Test state=latest with url - {{ method }} + flatpak: + name: http://127.0.0.1:8000/repo/com.dummy.App1.flatpakref + remote: dummy-remote + state: latest + method: "{{ method }}" + no_dependencies: true + register: url_latest_result + +- name: Verify state=latest test result - {{ method }} + assert: + that: + - url_latest_result is changed + msg: "state=present with url as name shall add flatpak when absent" + +- name: Test idempotency of state=latest with url - {{ method }} + flatpak: + name: http://127.0.0.1:8000/repo/com.dummy.App1.flatpakref + remote: dummy-remote + state: latest + method: "{{ method }}" + no_dependencies: true + register: double_url_latest_result + +- name: Verify idempotency of state=latest with url test result - {{ method }} + assert: + that: + - double_url_latest_result is not changed + msg: "state=present with url as name shall not do anything when flatpak is already present" + +- name: Cleanup after state=present with url test - {{ method }} + flatpak: + name: com.dummy.App1 + state: absent + method: "{{ method }}" + no_dependencies: true + # state=present with list of packages - name: Test addition with list - {{ method }} @@ -287,3 +365,84 @@ that: - double_removal_result is not changed msg: "state=absent shall not do anything when flatpak is not present" + +# state=latest with list of packages + +- name: Test state=latest with list - {{ method }} + flatpak: + name: + - com.dummy.App1 + - http://127.0.0.1:8000/repo/com.dummy.App2.flatpakref + remote: dummy-remote + state: latest + method: "{{ method }}" + no_dependencies: true + register: latest_result + +- name: Verify state=latest with list test result - {{ method }} + assert: + that: + - latest_result is changed + msg: "state=present shall add flatpak when absent" + +- name: Test idempotency of state=latest with list - {{ method }} + flatpak: + name: + - com.dummy.App1 + - http://127.0.0.1:8000/repo/com.dummy.App2.flatpakref + remote: dummy-remote + state: latest + method: "{{ method }}" + no_dependencies: true + register: double_latest_result + +- name: Verify idempotency of state=latest with list test result - {{ method }} + assert: + that: + - double_latest_result is not changed + msg: "state=present shall not do anything when flatpak is already present" + +- name: Test state=latest with list partially installed - {{ method }} + flatpak: + name: + - com.dummy.App1 + - http://127.0.0.1:8000/repo/com.dummy.App2.flatpakref + - com.dummy.App3 + remote: dummy-remote + state: latest + method: "{{ method }}" + no_dependencies: true + register: latest_result + +- name: Verify state=latest with list partially installed test result - {{ method }} + assert: + that: + - latest_result is changed + msg: "state=present shall add flatpak when absent" + +- name: Test idempotency of state=latest with list partially installed - {{ method }} + flatpak: + name: + - com.dummy.App1 + - http://127.0.0.1:8000/repo/com.dummy.App2.flatpakref + - com.dummy.App3 + remote: dummy-remote + state: latest + method: "{{ method }}" + no_dependencies: true + register: double_latest_result + +- name: Verify idempotency of state=latest with list partially installed test result - {{ method }} + assert: + that: + - double_latest_result is not changed + msg: "state=present shall not do anything when flatpak is already present" + +- name: Cleanup after state=present with list test - {{ method }} + flatpak: + name: + - com.dummy.App1 + - com.dummy.App2 + - com.dummy.App3 + state: absent + method: "{{ method }}" diff --git a/tests/integration/targets/gandi_livedns/aliases b/tests/integration/targets/gandi_livedns/aliases index f69a127f4d..bd1f024441 100644 --- a/tests/integration/targets/gandi_livedns/aliases +++ b/tests/integration/targets/gandi_livedns/aliases @@ -2,5 +2,4 @@ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -cloud/gandi unsupported diff --git a/tests/integration/targets/gandi_livedns/tasks/create_record.yml b/tests/integration/targets/gandi_livedns/tasks/create_record.yml index c3f1c17981..87056aa865 100644 --- a/tests/integration/targets/gandi_livedns/tasks/create_record.yml +++ b/tests/integration/targets/gandi_livedns/tasks/create_record.yml @@ -45,10 +45,10 @@ assert: that: - result is changed - - result.record['values'] == {{ item['values'] }} - - result.record.record == "{{ item.record }}" - - result.record.type == "{{ item.type }}" - - result.record.ttl == {{ item.ttl }} + - result.record['values'] == item['values'] + - result.record.record == item.record + - result.record.type == item.type + - result.record.ttl == item.ttl - name: test create a dns record idempotence community.general.gandi_livedns: @@ -63,7 +63,16 @@ assert: that: - result is not changed - - result.record['values'] == {{ item['values'] }} - - result.record.record == "{{ item.record }}" - - result.record.type == "{{ item.type }}" - - result.record.ttl == {{ item.ttl }} + - result.record['values'] == item['values'] + - result.record.record == item.record + - result.record.type == item.type + - result.record.ttl == item.ttl + +- name: test create a DNS record with personal access token + community.general.gandi_livedns: + personal_access_token: "{{ gandi_personal_access_token }}" + record: "{{ item.record }}" + domain: "{{ gandi_livedns_domain_name }}" + values: "{{ item['values'] }}" + ttl: "{{ item.ttl }}" + type: "{{ item.type }}" diff --git a/tests/integration/targets/gandi_livedns/tasks/update_record.yml b/tests/integration/targets/gandi_livedns/tasks/update_record.yml index a080560a75..5f19bfa244 100644 --- a/tests/integration/targets/gandi_livedns/tasks/update_record.yml +++ b/tests/integration/targets/gandi_livedns/tasks/update_record.yml @@ -17,10 +17,10 @@ assert: that: - result is changed - - result.record['values'] == {{ item.update_values | default(item['values']) }} - - result.record.record == "{{ item.record }}" - - result.record.type == "{{ item.type }}" - - result.record.ttl == {{ item.update_ttl | default(item.ttl) }} + - result.record['values'] == (item.update_values | default(item['values'])) + - result.record.record == item.record + - result.record.type == item.type + - result.record.ttl == (item.update_ttl | default(item.ttl)) - name: test update or add another dns record community.general.gandi_livedns: @@ -35,10 +35,10 @@ assert: that: - result is changed - - result.record['values'] == {{ item.update_values | default(item['values']) }} - - result.record.record == "{{ item.record }}" - - result.record.ttl == {{ item.update_ttl | default(item.ttl) }} - - result.record.type == "{{ item.type }}" + - result.record['values'] == (item.update_values | default(item['values'])) + - result.record.record == item.record + - result.record.ttl == (item.update_ttl | default(item.ttl)) + - result.record.type == item.type - name: test update or add another dns record idempotence community.general.gandi_livedns: @@ -53,7 +53,7 @@ assert: that: - result is not changed - - result.record['values'] == {{ item.update_values | default(item['values']) }} - - result.record.record == "{{ item.record }}" - - result.record.ttl == {{ item.update_ttl | default(item.ttl) }} - - result.record.type == "{{ item.type }}" + - result.record['values'] == (item.update_values | default(item['values'])) + - result.record.record == item.record + - result.record.ttl == (item.update_ttl | default(item.ttl)) + - result.record.type == item.type diff --git a/tests/integration/targets/git_config/tasks/unset_value.yml b/tests/integration/targets/git_config/tasks/unset_value.yml index dfa535a2d3..5f8c52c96f 100644 --- a/tests/integration/targets/git_config/tasks/unset_value.yml +++ b/tests/integration/targets/git_config/tasks/unset_value.yml @@ -18,6 +18,30 @@ scope: "{{ option_scope }}" register: get_result +- name: assert unset changed and deleted value + assert: + that: + - unset_result is changed + - unset_result.diff.before == option_value + "\n" + - unset_result.diff.after == "\n" + - get_result.config_value == '' + +- import_tasks: setup_value.yml + +- name: unsetting value with value specified + git_config: + name: "{{ option_name }}" + scope: "{{ option_scope }}" + value: "{{ option_value }}" + state: absent + register: unset_result + +- name: getting value + git_config: + name: "{{ option_name }}" + scope: "{{ option_scope }}" + register: get_result + - name: assert unset changed and deleted value assert: that: diff --git a/tests/integration/targets/homebrew/handlers/main.yml b/tests/integration/targets/homebrew/handlers/main.yml new file mode 100644 index 0000000000..90a2e8017d --- /dev/null +++ b/tests/integration/targets/homebrew/handlers/main.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: uninstall docker + community.general.homebrew: + name: docker + state: absent + become: true + become_user: "{{ brew_stat.stat.pw_name }}" diff --git a/tests/integration/targets/homebrew/tasks/casks.yml b/tests/integration/targets/homebrew/tasks/casks.yml index 42d3515bf2..ffbe67d158 100644 --- a/tests/integration/targets/homebrew/tasks/casks.yml +++ b/tests/integration/targets/homebrew/tasks/casks.yml @@ -12,13 +12,11 @@ - name: Find brew binary command: which brew register: brew_which - when: ansible_distribution in ['MacOSX'] - name: Get owner of brew binary stat: path: "{{ brew_which.stdout }}" register: brew_stat - when: ansible_distribution in ['MacOSX'] #- name: Use ignored-pinned option while upgrading all # homebrew: diff --git a/tests/integration/targets/homebrew/tasks/docker.yml b/tests/integration/targets/homebrew/tasks/docker.yml new file mode 100644 index 0000000000..c7f282ba2d --- /dev/null +++ b/tests/integration/targets/homebrew/tasks/docker.yml @@ -0,0 +1,23 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: MACOS | Find brew binary + command: which brew + register: brew_which + +- name: MACOS | Get owner of brew binary + stat: + path: "{{ brew_which.stdout }}" + register: brew_stat + +- name: MACOS | Install docker + community.general.homebrew: + name: docker + state: present + force_formula: true + become: true + become_user: "{{ brew_stat.stat.pw_name }}" + notify: + - uninstall docker diff --git a/tests/integration/targets/homebrew/tasks/formulae.yml b/tests/integration/targets/homebrew/tasks/formulae.yml index 1db3ef1a6a..1ca8d753e7 100644 --- a/tests/integration/targets/homebrew/tasks/formulae.yml +++ b/tests/integration/targets/homebrew/tasks/formulae.yml @@ -12,13 +12,11 @@ - name: Find brew binary command: which brew register: brew_which - when: ansible_distribution in ['MacOSX'] - name: Get owner of brew binary stat: path: "{{ brew_which.stdout }}" register: brew_stat - when: ansible_distribution in ['MacOSX'] #- name: Use ignored-pinned option while upgrading all # homebrew: diff --git a/tests/integration/targets/homebrew/tasks/main.yml b/tests/integration/targets/homebrew/tasks/main.yml index f5479917ea..00d0bcf31c 100644 --- a/tests/integration/targets/homebrew/tasks/main.yml +++ b/tests/integration/targets/homebrew/tasks/main.yml @@ -9,9 +9,8 @@ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -- block: - - include_tasks: 'formulae.yml' - - when: ansible_distribution in ['MacOSX'] block: + - include_tasks: 'formulae.yml' - include_tasks: 'casks.yml' + - include_tasks: 'docker.yml' diff --git a/tests/integration/targets/homebrew_services/aliases b/tests/integration/targets/homebrew_services/aliases new file mode 100644 index 0000000000..bd478505d9 --- /dev/null +++ b/tests/integration/targets/homebrew_services/aliases @@ -0,0 +1,9 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/1 +skip/aix +skip/freebsd +skip/rhel +skip/docker diff --git a/tests/integration/targets/homebrew_services/handlers/main.yml b/tests/integration/targets/homebrew_services/handlers/main.yml new file mode 100644 index 0000000000..18856120d0 --- /dev/null +++ b/tests/integration/targets/homebrew_services/handlers/main.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: uninstall black + community.general.homebrew: + name: black + state: absent + become: true + become_user: "{{ brew_stat.stat.pw_name }}" diff --git a/tests/integration/targets/homebrew_services/tasks/main.yml b/tests/integration/targets/homebrew_services/tasks/main.yml new file mode 100644 index 0000000000..1d524715ca --- /dev/null +++ b/tests/integration/targets/homebrew_services/tasks/main.yml @@ -0,0 +1,86 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Don't run this test for non-MacOS systems. +- meta: end_play + when: ansible_facts.distribution != 'MacOSX' + +- name: MACOS | Find brew binary + command: which brew + register: brew_which + +- name: MACOS | Get owner of brew binary + stat: + path: "{{ brew_which.stdout }}" + register: brew_stat + +- name: Homebrew Services test block + become: true + become_user: "{{ brew_stat.stat.pw_name }}" + block: + - name: MACOS | Install black + community.general.homebrew: + name: black + state: present + register: install_result + notify: + - uninstall black + + - name: Check the black service is installed + assert: + that: + - install_result is success + + - name: Start the black service + community.general.homebrew_services: + name: black + state: present + register: start_result + environment: + HOMEBREW_NO_ENV_HINTS: "1" + + - name: Check the black service is running + assert: + that: + - start_result is success + + - name: Start the black service when already started + community.general.homebrew_services: + name: black + state: present + register: start_result + environment: + HOMEBREW_NO_ENV_HINTS: "1" + + - name: Check for idempotency + assert: + that: + - start_result.changed == 0 + + - name: Restart the black service + community.general.homebrew_services: + name: black + state: restarted + register: restart_result + environment: + HOMEBREW_NO_ENV_HINTS: "1" + + - name: Check the black service is restarted + assert: + that: + - restart_result is success + + - name: Stop the black service + community.general.homebrew_services: + name: black + state: present + register: stop_result + environment: + HOMEBREW_NO_ENV_HINTS: "1" + + - name: Check the black service is stopped + assert: + that: + - stop_result is success diff --git a/tests/integration/targets/homectl/aliases b/tests/integration/targets/homectl/aliases index ea9b442302..a226b55851 100644 --- a/tests/integration/targets/homectl/aliases +++ b/tests/integration/targets/homectl/aliases @@ -11,3 +11,4 @@ skip/rhel9.0 # See https://www.reddit.com/r/Fedora/comments/si7nzk/homectl/ skip/rhel9.1 # See https://www.reddit.com/r/Fedora/comments/si7nzk/homectl/ skip/rhel9.2 # See https://www.reddit.com/r/Fedora/comments/si7nzk/homectl/ skip/rhel9.3 # See https://www.reddit.com/r/Fedora/comments/si7nzk/homectl/ +skip/rhel9.4 # See https://www.reddit.com/r/Fedora/comments/si7nzk/homectl/ diff --git a/tests/integration/targets/ini_file/tasks/main.yml b/tests/integration/targets/ini_file/tasks/main.yml index 0ed3c28172..8fd88074b2 100644 --- a/tests/integration/targets/ini_file/tasks/main.yml +++ b/tests/integration/targets/ini_file/tasks/main.yml @@ -16,7 +16,6 @@ - name: include tasks block: - - name: include tasks to perform basic tests include_tasks: tests/00-basic.yml @@ -50,3 +49,6 @@ - name: include tasks to test optional spaces in section headings include_tasks: tests/07-section_name_spaces.yml + + - name: include tasks to test section_has_values + include_tasks: tests/08-section.yml diff --git a/tests/integration/targets/ini_file/tasks/tests/08-section.yml b/tests/integration/targets/ini_file/tasks/tests/08-section.yml new file mode 100644 index 0000000000..4f3a135e11 --- /dev/null +++ b/tests/integration/targets/ini_file/tasks/tests/08-section.yml @@ -0,0 +1,341 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## testing section selection + +- name: test-section 1 - Create starting ini file + copy: + content: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + fav = lemonade + beverage = pineapple juice + + dest: "{{ output_file }}" + +- name: test-section 1 - Modify starting ini file + ini_file: + dest: "{{ output_file }}" + section: drinks + option: car + value: volvo + state: present + register: result1 + +- name: test-section 1 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 1 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + car = volvo + + [drinks] + fav = lemonade + beverage = pineapple juice + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 1 - Option was added to first section + assert: + that: + - result1 is changed + - result1.msg == 'option added' + - output1 == expected1 + +# ---------------- + +- name: test-section 2 - Create starting ini file + copy: + content: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + fav = lemonade + beverage = pineapple juice + + dest: "{{ output_file }}" + +- name: test-section 2 - Modify starting ini file + ini_file: + dest: "{{ output_file }}" + section: drinks + section_has_values: + - option: beverage + value: pineapple juice + option: car + value: volvo + state: present + register: result1 + +- name: test-section 2 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 2 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + fav = lemonade + beverage = pineapple juice + car = volvo + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 2 - Option added to second section specified with section_has_values + assert: + that: + - result1 is changed + - result1.msg == 'option added' + - output1 == expected1 + +# ---------------- + +- name: test-section 3 - Create starting ini file + copy: + content: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + fav = lemonade + beverage = pineapple juice + + dest: "{{ output_file }}" + +- name: test-section 3 - Modify starting ini file + ini_file: + dest: "{{ output_file }}" + section: drinks + section_has_values: + - option: beverage + value: pineapple juice + option: fav + value: lemonade + state: absent + register: result1 + +- name: test-section 3 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 3 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + beverage = pineapple juice + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 3 - Option was removed from specified section + assert: + that: + - result1 is changed + - result1.msg == 'option changed' + - output1 == expected1 + +# ---------------- + +- name: test-section 4 - Create starting ini file + copy: + content: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + fav = lemonade + beverage = pineapple juice + + dest: "{{ output_file }}" + +- name: test-section 4 - Modify starting ini file + ini_file: + dest: "{{ output_file }}" + section: drinks + section_has_values: + - option: beverage + value: alligator slime + option: fav + value: tea + state: present + register: result1 + +- name: test-section 4 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 4 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + fav = lemonade + beverage = pineapple juice + [drinks] + beverage = alligator slime + fav = tea + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 4 - New section created, including required values + assert: + that: + - result1 is changed + - result1.msg == 'section and option added' + - output1 == expected1 + +# ---------------- + +- name: test-section 5 - Modify test-section 4 result file + ini_file: + dest: "{{ output_file }}" + section: drinks + section_has_values: + - option: fav + value: lemonade + - option: beverage + value: pineapple juice + state: absent + register: result1 + +- name: test-section 5 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 5 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + beverage = alligator slime + fav = tea + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 5 - Section removed as specified + assert: + that: + - result1 is changed + - result1.msg == 'section removed' + - output1 == expected1 + +# ---------------- + +- name: test-section 6 - Modify test-section 5 result file with multiple values + ini_file: + dest: "{{ output_file }}" + section: drinks + section_has_values: + - option: fav + values: + - cherry + - lemon + - vanilla + - option: beverage + value: pineapple juice + state: present + option: fav + values: + - vanilla + - grape + exclusive: false + register: result1 + +- name: test-section 6 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 6 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + beverage = alligator slime + fav = tea + [drinks] + beverage = pineapple juice + fav = vanilla + fav = grape + fav = cherry + fav = lemon + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 6 - New section added + assert: + that: + - result1 is changed + - result1.msg == 'section and option added' + - output1 == expected1 + +# ---------------- + +- name: test-section 7 - Modify test-section 6 result file with exclusive value + ini_file: + dest: "{{ output_file }}" + section: drinks + section_has_values: + - option: fav + value: vanilla + state: present + option: fav + value: cherry + exclusive: true + register: result1 + +- name: test-section 7 - Read modified file + slurp: + src: "{{ output_file }}" + register: output_content + +- name: test-section 7 - Create expected result + set_fact: + expected1: | + [drinks] + fav = lemonade + beverage = orange juice + + [drinks] + beverage = alligator slime + fav = tea + [drinks] + beverage = pineapple juice + fav = cherry + output1: "{{ output_content.content | b64decode }}" + +- name: test-section 7 - Option changed + assert: + that: + - result1 is changed + - result1.msg == 'option changed' + - output1 == expected1 diff --git a/tests/integration/targets/iptables_state/aliases b/tests/integration/targets/iptables_state/aliases index 5a02a630bc..76c58041b6 100644 --- a/tests/integration/targets/iptables_state/aliases +++ b/tests/integration/targets/iptables_state/aliases @@ -10,3 +10,5 @@ skip/freebsd # no iptables/netfilter (Linux specific) skip/osx # no iptables/netfilter (Linux specific) skip/macos # no iptables/netfilter (Linux specific) skip/aix # no iptables/netfilter (Linux specific) + +skip/ubuntu22.04 # TODO there's a problem here! diff --git a/tests/integration/targets/iso_extract/aliases b/tests/integration/targets/iso_extract/aliases index 5ddca1ecbb..27e07941a5 100644 --- a/tests/integration/targets/iso_extract/aliases +++ b/tests/integration/targets/iso_extract/aliases @@ -11,7 +11,9 @@ skip/rhel9.0 # FIXME skip/rhel9.1 # FIXME skip/rhel9.2 # FIXME skip/rhel9.3 # FIXME +skip/rhel9.4 # FIXME skip/freebsd12.4 # FIXME skip/freebsd13.2 # FIXME skip/freebsd13.3 # FIXME skip/freebsd14.0 # FIXME +skip/freebsd14.1 # FIXME diff --git a/tests/integration/targets/keycloak_client/tasks/main.yml b/tests/integration/targets/keycloak_client/tasks/main.yml index 5e7c7fae39..e1a7d2ebfb 100644 --- a/tests/integration/targets/keycloak_client/tasks/main.yml +++ b/tests/integration/targets/keycloak_client/tasks/main.yml @@ -103,3 +103,131 @@ assert: that: - check_client_when_present_and_changed is changed + +- name: Desire client with flow binding overrides + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + redirect_uris: '{{redirect_uris1}}' + attributes: '{{client_attributes1}}' + protocol_mappers: '{{protocol_mappers1}}' + authentication_flow_binding_overrides: + browser_name: browser + direct_grant_name: direct grant + register: desire_client_with_flow_binding_overrides + +- name: Assert flows are set + assert: + that: + - desire_client_with_flow_binding_overrides is changed + - "'authenticationFlowBindingOverrides' in desire_client_with_flow_binding_overrides.end_state" + - desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.browser | length > 0 + - desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.direct_grant | length > 0 + +- name: Backup flow UUIDs + set_fact: + flow_browser_uuid: "{{ desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.browser }}" + flow_direct_grant_uuid: "{{ desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.direct_grant }}" + +- name: Desire client with flow binding overrides remove direct_grant_name + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + redirect_uris: '{{redirect_uris1}}' + attributes: '{{client_attributes1}}' + protocol_mappers: '{{protocol_mappers1}}' + authentication_flow_binding_overrides: + browser_name: browser + register: desire_client_with_flow_binding_overrides + +- name: Assert flows are updated + assert: + that: + - desire_client_with_flow_binding_overrides is changed + - "'authenticationFlowBindingOverrides' in desire_client_with_flow_binding_overrides.end_state" + - desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.browser | length > 0 + - "'direct_grant' not in desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides" + +- name: Desire client with flow binding overrides remove browser add direct_grant + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + redirect_uris: '{{redirect_uris1}}' + attributes: '{{client_attributes1}}' + protocol_mappers: '{{protocol_mappers1}}' + authentication_flow_binding_overrides: + direct_grant_name: direct grant + register: desire_client_with_flow_binding_overrides + +- name: Assert flows are updated + assert: + that: + - desire_client_with_flow_binding_overrides is changed + - "'authenticationFlowBindingOverrides' in desire_client_with_flow_binding_overrides.end_state" + - "'browser' not in desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides" + - desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.direct_grant | length > 0 + +- name: Desire client with flow binding overrides with UUIDs + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + redirect_uris: '{{redirect_uris1}}' + attributes: '{{client_attributes1}}' + protocol_mappers: '{{protocol_mappers1}}' + authentication_flow_binding_overrides: + browser: "{{ flow_browser_uuid }}" + direct_grant: "{{ flow_direct_grant_uuid }}" + register: desire_client_with_flow_binding_overrides + +- name: Assert flows are updated + assert: + that: + - desire_client_with_flow_binding_overrides is changed + - "'authenticationFlowBindingOverrides' in desire_client_with_flow_binding_overrides.end_state" + - desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.browser == flow_browser_uuid + - desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides.direct_grant == flow_direct_grant_uuid + +- name: Unset flow binding overrides + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + redirect_uris: '{{redirect_uris1}}' + attributes: '{{client_attributes1}}' + protocol_mappers: '{{protocol_mappers1}}' + authentication_flow_binding_overrides: + browser: "{{ None }}" + direct_grant: null + register: desire_client_with_flow_binding_overrides + +- name: Assert flows are removed + assert: + that: + - desire_client_with_flow_binding_overrides is changed + - "'authenticationFlowBindingOverrides' in desire_client_with_flow_binding_overrides.end_state" + - "'browser' not in desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides" + - "'direct_grant' not in desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides" \ No newline at end of file diff --git a/tests/integration/targets/keycloak_client_rolescope/README.md b/tests/integration/targets/keycloak_client_rolescope/README.md new file mode 100644 index 0000000000..cd1152dad8 --- /dev/null +++ b/tests/integration/targets/keycloak_client_rolescope/README.md @@ -0,0 +1,20 @@ + +# Running keycloak_client_rolescope module integration test + +To run Keycloak component info module's integration test, start a keycloak server using Docker: + + docker run -d --rm --name mykeycloak -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:latest start-dev --http-relative-path /auth + +Run integration tests: + + ansible-test integration -v keycloak_client_rolescope --allow-unsupported --docker fedora35 --docker-network host + +Cleanup: + + docker stop mykeycloak + + diff --git a/tests/integration/targets/keycloak_client_rolescope/aliases b/tests/integration/targets/keycloak_client_rolescope/aliases new file mode 100644 index 0000000000..bd1f024441 --- /dev/null +++ b/tests/integration/targets/keycloak_client_rolescope/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +unsupported diff --git a/tests/integration/targets/keycloak_client_rolescope/tasks/main.yml b/tests/integration/targets/keycloak_client_rolescope/tasks/main.yml new file mode 100644 index 0000000000..8675c9548d --- /dev/null +++ b/tests/integration/targets/keycloak_client_rolescope/tasks/main.yml @@ -0,0 +1,317 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Wait for Keycloak + uri: + url: "{{ url }}/admin/" + status_code: 200 + validate_certs: no + register: result + until: result.status == 200 + retries: 10 + delay: 10 + +- name: Delete realm if exists + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + state: absent + +- name: Create realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ realm }}" + realm: "{{ realm }}" + state: present + +- name: Create a Keycloak realm role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ item }}" + realm: "{{ realm }}" + with_items: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + +- name: Client private + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_private }}" + state: present + redirect_uris: + - "https://my-backend-api.c.org/" + fullScopeAllowed: True + attributes: '{{client_attributes1}}' + public_client: False + +- name: Create a Keycloak client role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ item }}" + realm: "{{ realm }}" + client_id: "{{ client_name_private }}" + with_items: + - "{{ client_role_admin }}" + - "{{ client_role_user }}" + +- name: Client public + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + redirect_uris: + - "https://my-onepage-app-frontend.c.org/" + attributes: '{{client_attributes1}}' + full_scope_allowed: False + public_client: True + + +- name: Map roles to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + client_scope_id: "{{ client_name_private }}" + role_names: + - "{{ client_role_admin }}" + - "{{ client_role_user }}" + register: result + +- name: Assert mapping created + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: remap role user to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + client_scope_id: "{{ client_name_private }}" + role_names: + - "{{ client_role_user }}" + register: result + +- name: Assert mapping created + assert: + that: + - result is not changed + - result.end_state | length == 2 + +- name: Remove Map role admin to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + client_scope_id: "{{ client_name_private }}" + role_names: + - "{{ client_role_admin }}" + state: absent + register: result + +- name: Assert mapping deleted + assert: + that: + - result is changed + - result.end_state | length == 1 + - result.end_state[0].name == client_role_user + +- name: Map missing roles to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + client_scope_id: "{{ client_name_private }}" + role_names: + - "{{ client_role_admin }}" + - "{{ client_role_not_exists }}" + ignore_errors: true + register: result + +- name: Assert failed mapping missing role + assert: + that: + - result is failed + +- name: Map roles duplicate + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + client_scope_id: "{{ client_name_private }}" + role_names: + - "{{ client_role_admin }}" + - "{{ client_role_admin }}" + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Map roles to private client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_private }}" + role_names: + - "{{ realm_role_admin }}" + ignore_errors: true + register: result + +- name: Assert failed mapping role to full scope client + assert: + that: + - result is failed + +- name: Map realm role to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + role_names: + - "{{ realm_role_admin }}" + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 1 + +- name: Map two realm roles to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + role_names: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Unmap all realm roles to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + role_names: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + state: absent + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 0 + +- name: Map missing realm role to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + role_names: + - "{{ realm_role_not_exists }}" + ignore_errors: true + register: result + +- name: Assert failed mapping missing realm role + assert: + that: + - result is failed + +- name: Check-mode try to Map realm roles to public client + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + role_names: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + check_mode: true + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Check-mode step two, check if change where applied + community.general.keycloak_client_rolescope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name_public }}" + role_names: [] + register: result + +- name: Assert result + assert: + that: + - result is not changed + - result.end_state | length == 0 \ No newline at end of file diff --git a/tests/integration/targets/keycloak_client_rolescope/vars/main.yml b/tests/integration/targets/keycloak_client_rolescope/vars/main.yml new file mode 100644 index 0000000000..8bd59398b7 --- /dev/null +++ b/tests/integration/targets/keycloak_client_rolescope/vars/main.yml @@ -0,0 +1,26 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm + + +client_name_private: backend-client-private +client_role_admin: client-role-admin +client_role_user: client-role-user +client_role_not_exists: client-role-missing + +client_name_public: frontend-client-public + + +realm_role_admin: realm-role-admin +realm_role_user: realm-role-user +realm_role_not_exists: client-role-missing + + +client_attributes1: {"backchannel.logout.session.required": true, "backchannel.logout.revoke.offline.tokens": false, "client.secret.creation.time": 0} diff --git a/tests/integration/targets/keycloak_group/tasks/main.yml b/tests/integration/targets/keycloak_group/tasks/main.yml index 8b115e3a28..f807b0640d 100644 --- a/tests/integration/targets/keycloak_group/tasks/main.yml +++ b/tests/integration/targets/keycloak_group/tasks/main.yml @@ -10,8 +10,8 @@ command: start-dev env: KC_HTTP_RELATIVE_PATH: /auth - KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: password + KEYCLOAK_ADMIN: "{{ admin_user }}" + KEYCLOAK_ADMIN_PASSWORD: "{{ admin_password }}" ports: - "8080:8080" detach: true diff --git a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml index afad9740ed..fa118ed1d9 100644 --- a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml +++ b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml @@ -62,6 +62,7 @@ - result.existing == {} - result.end_state.alias == "{{ idp }}" - result.end_state.mappers != [] + - result.end_state.config.client_secret = "**********" - name: Update existing identity provider (no change) community.general.keycloak_identity_provider: diff --git a/tests/integration/targets/keycloak_userprofile/aliases b/tests/integration/targets/keycloak_userprofile/aliases new file mode 100644 index 0000000000..bd1f024441 --- /dev/null +++ b/tests/integration/targets/keycloak_userprofile/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +unsupported diff --git a/tests/integration/targets/keycloak_userprofile/meta/main.yml b/tests/integration/targets/keycloak_userprofile/meta/main.yml new file mode 100644 index 0000000000..c583a8fc22 --- /dev/null +++ b/tests/integration/targets/keycloak_userprofile/meta/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# dependencies: +# - setup_docker diff --git a/tests/integration/targets/keycloak_userprofile/readme.adoc b/tests/integration/targets/keycloak_userprofile/readme.adoc new file mode 100644 index 0000000000..943dfaf542 --- /dev/null +++ b/tests/integration/targets/keycloak_userprofile/readme.adoc @@ -0,0 +1,27 @@ +// Copyright (c) Ansible Project +// GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +// SPDX-License-Identifier: GPL-3.0-or-later + +To be able to run these integration tests a keycloak server must be +reachable under a specific url with a specific admin user and password. +The exact values expected for these parameters can be found in +'vars/main.yml' file. A simple way to do this is to use the official +keycloak docker images like this: + +---- +docker run --name mykeycloak -p 8080:8080 -e KC_HTTP_RELATIVE_PATH= -e KEYCLOAK_ADMIN= -e KEYCLOAK_ADMIN_PASSWORD= quay.io/keycloak/keycloak:24.0.5 start-dev +---- + +Example with concrete values inserted: + +---- +docker run --name mykeycloak -p 8080:8080 -e KC_HTTP_RELATIVE_PATH=/auth -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:24.0.5 start-dev +---- + +This test suite can run against a fresh unconfigured server instance +(no preconfiguration required) and cleans up after itself (undoes all +its config changes) as long as it runs through completely. While its active +it changes the server configuration in the following ways: + + * creating, modifying and deleting some keycloak userprofiles + diff --git a/tests/integration/targets/keycloak_userprofile/tasks/main.yml b/tests/integration/targets/keycloak_userprofile/tasks/main.yml new file mode 100644 index 0000000000..37b65d35ed --- /dev/null +++ b/tests/integration/targets/keycloak_userprofile/tasks/main.yml @@ -0,0 +1,301 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +- name: Start container + community.docker.docker_container: + name: mykeycloak + image: "quay.io/keycloak/keycloak:24.0.5" + command: start-dev + env: + KC_HTTP_RELATIVE_PATH: /auth + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: password + ports: + - "8080:8080" + detach: true + auto_remove: true + memory: 2200M + +- name: Check default ports + ansible.builtin.wait_for: + host: "localhost" + port: "8080" + state: started # Port should be open + delay: 30 # Wait before first check + timeout: 50 # Stop checking after timeout (sec) + +- name: Remove Keycloak test realm to avoid failures from previous failed runs + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + id: "{{ realm }}" + state: absent + +- name: Create Keycloak test realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + id: "{{ realm }}" + state: present + +- name: Create default User Profile (check mode) + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + parent_id: "{{ realm }}" + config: "{{ config_default }}" + check_mode: true + register: result + +- name: Assert that User Profile would be created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg == "Userprofile declarative-user-profile would be created" + +- name: Create default User Profile + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_default }}" + diff: true + register: result + +- name: Assert that User Profile was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg == "Userprofile declarative-user-profile created" + +- name: Create default User Profile (test for idempotency) + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_default }}" + register: result + +- name: Assert that User Profile was in sync + assert: + that: + - result is not changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg == "Userprofile declarative-user-profile was in sync" + +- name: Update default User Profile (check mode) + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_updated }}" + check_mode: true + register: result + +- name: Assert that User Profile would be changed + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg.startswith("Userprofile declarative-user-profile would be changed:") + +- name: Update default User Profile + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_updated }}" + diff: true + register: result + +- name: Assert that User Profile changed + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg.startswith("Userprofile declarative-user-profile changed:") + +- name: Update default User Profile (test for idempotency) + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_updated }}" + register: result + +- name: Assert that User Profile was in sync + assert: + that: + - result is not changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg == "Userprofile declarative-user-profile was in sync" + +## No force implemented +# - name: Force update default User Profile +# community.general.keycloak_userprofile: +# auth_keycloak_url: "{{ url }}" +# auth_realm: "{{ admin_realm }}" +# auth_username: "{{ admin_user }}" +# auth_password: "{{ admin_password }}" +# force: true +# state: present +# parent_id: "{{ realm }}" +# config: "{{ config_updated }}" +# register: result +# +# - name: Assert that forced update ran correctly +# assert: +# that: +# - result is changed +# - result.end_state != {} +# - result.end_state.providerId == "declarative-user-profile" +# - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" +# - result.msg == "Userprofile declarative-user-profile was forcibly updated" + +- name: Remove default User Profile + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + parent_id: "{{ realm }}" + config: "{{ config_default }}" + diff: true + register: result + +- name: Assert that User Profile was deleted + assert: + that: + - result is changed + - result.end_state == {} + - result.msg == "Userprofile declarative-user-profile deleted" + +- name: Remove default User Profile (test for idempotency) + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: absent + parent_id: "{{ realm }}" + config: "{{ config_default }}" + register: result + +- name: Assert that User Profile not present + assert: + that: + - result is not changed + - result.end_state == {} + - result.msg == "Userprofile declarative-user-profile not present" + +- name: Create User Profile with unmanaged attributes ENABLED + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_unmanaged_attributes_enabled }}" + diff: true + register: result + +- name: Assert that User Profile was created + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg == "Userprofile declarative-user-profile created" + +- name: Attempt to change the User Profile to unmanaged ADMIN_EDIT + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_unmanaged_attributes_admin_edit }}" + diff: true + register: result + +- name: Assert that User Profile was changed + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg.startswith("Userprofile declarative-user-profile changed:") + +- name: Attempt to change the User Profile to unmanaged ADMIN_VIEW + community.general.keycloak_userprofile: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + state: present + parent_id: "{{ realm }}" + config: "{{ config_unmanaged_attributes_admin_view }}" + diff: true + register: result + +- name: Assert that User Profile was changed + assert: + that: + - result is changed + - result.end_state != {} + - result.end_state.providerId == "declarative-user-profile" + - result.end_state.providerType == "org.keycloak.userprofile.UserProfileProvider" + - result.msg.startswith("Userprofile declarative-user-profile changed:") + +- name: Remove Keycloak test realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + id: "{{ realm }}" + state: absent diff --git a/tests/integration/targets/keycloak_userprofile/vars/main.yml b/tests/integration/targets/keycloak_userprofile/vars/main.yml new file mode 100644 index 0000000000..1f8ae6c823 --- /dev/null +++ b/tests/integration/targets/keycloak_userprofile/vars/main.yml @@ -0,0 +1,111 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: realm_userprofile_test +attributes_default: + - name: username + displayName: ${username} + validations: + length: + min: 3 + max: 255 + usernameProhibitedCharacters: {} + up_username_not_idn_homograph: {} + annotations: {} + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: email + displayName: ${email} + validations: + email: {} + length: + max: 255 + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: firstName + displayName: ${firstName} + validations: + length: + max: 255 + personNameProhibitedCharacters: {} + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false + - name: lastName + displayName: ${lastName} + validations: + length: + max: 255 + person_name_prohibited_characters: {} + annotations: {} + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false +attributes_additional: + - name: additionalAttribute + displayName: additionalAttribute + group: user-metadata + required: + roles: + - user + permissions: + view: + - admin + - user + edit: [] + multivalued: false +groups_default: + - name: user-metadata + displayHeader: User metadata + displayDescription: Attributes, which refer to user metadata +config_default: + kc_user_profile_config: + - attributes: "{{ attributes_default }}" + groups: "{{ groups_default }}" +config_updated: + kc_user_profile_config: + - attributes: "{{ attributes_default + attributes_additional }}" + groups: "{{ groups_default }}" +config_unmanaged_attributes_enabled: + kc_user_profile_config: + - unmanagedAttributePolicy: ENABLED + attributes: "{{ attributes_default }}" +config_unmanaged_attributes_admin_edit: + kc_user_profile_config: + - unmanagedAttributePolicy: ADMIN_EDIT + attributes: "{{ attributes_default }}" +config_unmanaged_attributes_admin_view: + kc_user_profile_config: + - unmanagedAttributePolicy: ADMIN_VIEW + attributes: "{{ attributes_default }}" diff --git a/tests/integration/targets/locale_gen/vars/main.yml b/tests/integration/targets/locale_gen/vars/main.yml index 44327ddd31..23358e6374 100644 --- a/tests/integration/targets/locale_gen/vars/main.yml +++ b/tests/integration/targets/locale_gen/vars/main.yml @@ -15,3 +15,12 @@ locale_list_basic: - localegen: eo locales: [eo] skip_removal: false + - localegen: + - ar_BH.UTF-8 + - tr_CY.UTF-8 + locales: + - ar_BH.UTF-8 + - ar_BH.utf8 + - tr_CY.UTF-8 + - tr_CY.utf8 + skip_removal: false diff --git a/tests/integration/targets/lookup_lmdb_kv/test.yml b/tests/integration/targets/lookup_lmdb_kv/test.yml index 217c020cac..8a88bca456 100644 --- a/tests/integration/targets/lookup_lmdb_kv/test.yml +++ b/tests/integration/targets/lookup_lmdb_kv/test.yml @@ -19,13 +19,13 @@ - item.0 == 'nl' - item.1 == 'Netherlands' vars: - - lmdb_kv_db: jp.mdb + lmdb_kv_db: jp.mdb with_community.general.lmdb_kv: - n* - assert: that: - item == 'Belgium' vars: - - lmdb_kv_db: jp.mdb + lmdb_kv_db: jp.mdb with_community.general.lmdb_kv: - be diff --git a/tests/integration/targets/lookup_merge_variables/runme.sh b/tests/integration/targets/lookup_merge_variables/runme.sh index 4e66476be4..ada6908dd7 100755 --- a/tests/integration/targets/lookup_merge_variables/runme.sh +++ b/tests/integration/targets/lookup_merge_variables/runme.sh @@ -14,3 +14,6 @@ ANSIBLE_MERGE_VARIABLES_PATTERN_TYPE=suffix \ ANSIBLE_LOG_PATH=/tmp/ansible-test-merge-variables \ ansible-playbook -i test_inventory_all_hosts.yml test_all_hosts.yml "$@" + +ANSIBLE_LOG_PATH=/tmp/ansible-test-merge-variables \ + ansible-playbook -i test_cross_host_merge_inventory.yml test_cross_host_merge_play.yml "$@" diff --git a/tests/integration/targets/lookup_merge_variables/test_cross_host_merge_inventory.yml b/tests/integration/targets/lookup_merge_variables/test_cross_host_merge_inventory.yml new file mode 100644 index 0000000000..938457023e --- /dev/null +++ b/tests/integration/targets/lookup_merge_variables/test_cross_host_merge_inventory.yml @@ -0,0 +1,33 @@ +--- +# Copyright (c) 2020, Thales Netherlands +# Copyright (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +common: + vars: + provider_instances: + servicedata1: + host: "{{ hostvars[groups['provider'] | first].inventory_hostname }}" + user: usr + pass: pwd + servicedata2: + host: down + user: usr2 + pass: pwd2 + hosts: + host1: + host2: + +consumer: + vars: + service_data: "{{ provider_instances.servicedata1 }}" + merge2__1: "{{ service_data }}" # service_data is a variable only known to host2, so normally it´s not available for host1 that is performing the merge + hosts: + host2: + +provider: + vars: + merge_result: "{{ lookup('community.general.merge_variables', 'merge2__', pattern_type='prefix', groups=['consumer']) }}" + hosts: + host1: diff --git a/tests/integration/targets/lookup_merge_variables/test_cross_host_merge_play.yml b/tests/integration/targets/lookup_merge_variables/test_cross_host_merge_play.yml new file mode 100644 index 0000000000..51cd6f1ba3 --- /dev/null +++ b/tests/integration/targets/lookup_merge_variables/test_cross_host_merge_play.yml @@ -0,0 +1,21 @@ +--- +# Copyright (c) 2020, Thales Netherlands +# Copyright (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Test merge_variables lookup plugin (merging host reference variables) + hosts: host1 + connection: local + gather_facts: false + tasks: + - name: Print merge result + ansible.builtin.debug: + msg: "{{ merge_result }}" + - name: Validate merge result + ansible.builtin.assert: + that: + - "merge_result | length == 3" + - "merge_result.host == 'host1'" + - "merge_result.user == 'usr'" + - "merge_result.pass == 'pwd'" diff --git a/tests/integration/targets/module_helper/library/mdepfail.py b/tests/integration/targets/module_helper/library/mdepfail.py index 92ebbde6e8..b61c32a4da 100644 --- a/tests/integration/targets/module_helper/library/mdepfail.py +++ b/tests/integration/targets/module_helper/library/mdepfail.py @@ -30,10 +30,10 @@ EXAMPLES = "" RETURN = "" +from ansible_collections.community.general.plugins.module_utils import deps from ansible_collections.community.general.plugins.module_utils.module_helper import ModuleHelper -from ansible.module_utils.basic import missing_required_lib -with ModuleHelper.dependency("nopackagewiththisname", missing_required_lib("nopackagewiththisname")): +with deps.declare("nopackagewiththisname"): import nopackagewiththisname # noqa: F401, pylint: disable=unused-import @@ -50,6 +50,7 @@ class MSimple(ModuleHelper): def __init_module__(self): self.vars.set('value', None) self.vars.set('abc', "abc", diff=True) + deps.validate(self.module) def __run__(self): if (0 if self.vars.a is None else self.vars.a) >= 100: diff --git a/tests/integration/targets/module_helper/library/mstate.py b/tests/integration/targets/module_helper/library/mstate.py index bfaab03755..b3b4ed5e69 100644 --- a/tests/integration/targets/module_helper/library/mstate.py +++ b/tests/integration/targets/module_helper/library/mstate.py @@ -49,6 +49,7 @@ class MState(StateModuleHelper): state=dict(type='str', choices=['join', 'b_x_a', 'c_x_a', 'both_x_a', 'nop'], default='join'), ), ) + use_old_vardict = False def __init_module__(self): self.vars.set('result', "abc", diff=True) diff --git a/tests/integration/targets/mqtt/tasks/main.yml b/tests/integration/targets/mqtt/tasks/main.yml index 0beb1b3b27..3fd11643ee 100644 --- a/tests/integration/targets/mqtt/tasks/main.yml +++ b/tests/integration/targets/mqtt/tasks/main.yml @@ -11,4 +11,4 @@ - include_tasks: ubuntu.yml when: - ansible_distribution == 'Ubuntu' - - ansible_distribution_release not in ['focal', 'jammy'] + - ansible_distribution_release not in ['focal', 'jammy', 'noble'] diff --git a/tests/integration/targets/odbc/aliases b/tests/integration/targets/odbc/aliases index 91a6167251..ceb043895a 100644 --- a/tests/integration/targets/odbc/aliases +++ b/tests/integration/targets/odbc/aliases @@ -11,4 +11,5 @@ skip/rhel9.0 skip/rhel9.1 skip/rhel9.2 skip/rhel9.3 +skip/rhel9.4 skip/freebsd diff --git a/tests/integration/targets/one_vnet/aliases b/tests/integration/targets/one_vnet/aliases new file mode 100644 index 0000000000..100ba0f979 --- /dev/null +++ b/tests/integration/targets/one_vnet/aliases @@ -0,0 +1,7 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/generic/1 +cloud/opennebula +disabled # FIXME - when this is fixed, also re-enable the generic tests in CI! diff --git a/tests/integration/targets/one_vnet/tasks/main.yml b/tests/integration/targets/one_vnet/tasks/main.yml new file mode 100644 index 0000000000..084d4758ad --- /dev/null +++ b/tests/integration/targets/one_vnet/tasks/main.yml @@ -0,0 +1,173 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Create a new template +- name: Create a new network + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: bridge-network + template: | + VN_MAD = "bridge" + BRIDGE = "br0" + BRIDGE_TYPE = "linux" + AR=[ + TYPE = "IP4", + IP = "192.0.2.2", + SIZE = "20" + ] + DNS = "192.0.2.1" + GATEWAY = "192.0.2.1" + register: result + +- name: Assert that network is created + assert: + that: + - result is changed + + +# Updating a network +- name: Update an existing network + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: bridge-network + template: | + VN_MAD = "bridge" + BRIDGE = "br0" + BRIDGE_TYPE = "linux" + AR=[ + TYPE = "IP4", + IP = "192.0.2.2", + SIZE = "20" + ] + DNS = "192.0.2.220" + GATEWAY = "192.0.2.1" + register: result + +- name: Assert that network is changed + assert: + that: + - result is changed + +# Testing idempotence using the same template as in previous task +- name: Update an existing network with the same changes again + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: bridge-network + template: | + VN_MAD = "bridge" + BRIDGE = "br0" + BRIDGE_TYPE = "linux" + AR=[ + TYPE = "IP4", + IP = "192.0.2.2", + SIZE = "20" + ] + DNS = "192.0.2.220" + GATEWAY = "192.0.2.1" + register: result + +- name: Assert that network is not changed + assert: + that: + - result is not changed + + +# Deletion of networks +- name: Delete a nonexisting network + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: i-do-not-exists + state: absent + register: result + +- name: Assert that network is not changed + assert: + that: + - result is not changed + +- name: Delete an existing network + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: bridge-network + state: absent + register: result + +- name: Assert that network was deleted + assert: + that: + - result is changed + +# Trying to run with wrong arguments +- name: Try to create use network with state=present and without the template parameter + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: bridge-network + state: present + register: result + ignore_errors: true + +- name: Assert that it failed because network is missing + assert: + that: + - result is failed + +- name: Try to create network with template but without specifying the name parameter + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + id: 0 + state: present + template: | + VN_MAD = "bridge" + BRIDGE = "br0" + BRIDGE_TYPE = "linux" + AR=[ + TYPE = "IP4", + IP = "192.0.2.2", + SIZE = "20" + ] + DNS = "192.0.2.220" + GATEWAY = "192.0.2.1" + register: result + ignore_errors: true + +- name: Assert that it failed because name is required for initial creation + assert: + that: + - result is failed + +- name: Try to use both ID and name at the same time + one_vnet: + api_url: "{{ opennebula_url }}" + api_username: "{{ opennebula_username }}" + api_password: "{{ opennebula_password }}" + name: + id: 0 + state: present + register: result + ignore_errors: true + +- name: Assert that it failed because you can use only one at the time + assert: + that: + - result is failed diff --git a/tests/integration/targets/pipx/aliases b/tests/integration/targets/pipx/aliases index 66e6e1a3e6..9f87ec3480 100644 --- a/tests/integration/targets/pipx/aliases +++ b/tests/integration/targets/pipx/aliases @@ -6,4 +6,3 @@ azp/posix/2 destructive skip/python2 skip/python3.5 -disabled # TODO diff --git a/tests/integration/targets/pipx/files/spec.json b/tests/integration/targets/pipx/files/spec.json new file mode 100644 index 0000000000..3c85125337 --- /dev/null +++ b/tests/integration/targets/pipx/files/spec.json @@ -0,0 +1,91 @@ +{ + "pipx_spec_version": "0.1", + "venvs": { + "black": { + "metadata": { + "injected_packages": {}, + "main_package": { + "app_paths": [ + { + "__Path__": "/home/az/.local/pipx/venvs/black/bin/black", + "__type__": "Path" + }, + { + "__Path__": "/home/az/.local/pipx/venvs/black/bin/blackd", + "__type__": "Path" + } + ], + "app_paths_of_dependencies": {}, + "apps": [ + "black", + "blackd" + ], + "apps_of_dependencies": [], + "include_apps": true, + "include_dependencies": false, + "man_pages": [], + "man_pages_of_dependencies": [], + "man_paths": [], + "man_paths_of_dependencies": {}, + "package": "black", + "package_or_url": "black", + "package_version": "24.8.0", + "pinned": false, + "pip_args": [], + "suffix": "" + }, + "pipx_metadata_version": "0.5", + "python_version": "Python 3.11.9", + "source_interpreter": { + "__Path__": "/home/az/.pyenv/versions/3.11.9/bin/python3.11", + "__type__": "Path" + }, + "venv_args": [] + } + }, + "pycowsay": { + "metadata": { + "injected_packages": {}, + "main_package": { + "app_paths": [ + { + "__Path__": "/home/az/.local/pipx/venvs/pycowsay/bin/pycowsay", + "__type__": "Path" + } + ], + "app_paths_of_dependencies": {}, + "apps": [ + "pycowsay" + ], + "apps_of_dependencies": [], + "include_apps": true, + "include_dependencies": false, + "man_pages": [ + "man6/pycowsay.6" + ], + "man_pages_of_dependencies": [], + "man_paths": [ + { + "__Path__": "/home/az/.local/pipx/venvs/pycowsay/share/man/man6/pycowsay.6", + "__type__": "Path" + } + ], + "man_paths_of_dependencies": {}, + "package": "pycowsay", + "package_or_url": "pycowsay", + "package_version": "0.0.0.2", + "pinned": false, + "pip_args": [], + "suffix": "" + }, + "pipx_metadata_version": "0.5", + "python_version": "Python 3.11.9", + "source_interpreter": { + "__Path__": "/home/az/.pyenv/versions/3.11.9/bin/python3.11", + "__type__": "Path" + }, + "venv_args": [] + } + }, + } +} diff --git a/tests/integration/targets/pipx/files/spec.json.license b/tests/integration/targets/pipx/files/spec.json.license new file mode 100644 index 0000000000..a1390a69ed --- /dev/null +++ b/tests/integration/targets/pipx/files/spec.json.license @@ -0,0 +1,3 @@ +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/integration/targets/pipx/tasks/main.yml b/tests/integration/targets/pipx/tasks/main.yml index 7eb0f11a6c..30e96ef1bf 100644 --- a/tests/integration/targets/pipx/tasks/main.yml +++ b/tests/integration/targets/pipx/tasks/main.yml @@ -171,7 +171,7 @@ state: latest register: install_tox_latest_with_preinstall_again -- name: install application latest tox +- name: install application latest tox (force) community.general.pipx: name: tox state: latest @@ -217,125 +217,42 @@ - "'tox' not in uninstall_tox_again.application" ############################################################################## -- name: ensure application ansible-lint is uninstalled - community.general.pipx: - name: ansible-lint - state: absent -- name: install application ansible-lint - community.general.pipx: - name: ansible-lint - register: install_ansible_lint +- name: Include testcase for inject packages + ansible.builtin.include_tasks: testcase-injectpkg.yml -- name: inject packages - community.general.pipx: - state: inject - name: ansible-lint - inject_packages: - - licenses - register: inject_pkgs_ansible_lint +- name: Include testcase for jupyter + ansible.builtin.include_tasks: testcase-jupyter.yml -- name: inject packages with apps - community.general.pipx: - state: inject - name: ansible-lint - inject_packages: - - black - install_apps: true - register: inject_pkgs_apps_ansible_lint +- name: Include testcase for old site-wide + ansible.builtin.include_tasks: testcase-oldsitewide.yml -- name: cleanup ansible-lint - community.general.pipx: - state: absent - name: ansible-lint - register: uninstall_ansible_lint +- name: Include testcase for issue 7497 + ansible.builtin.include_tasks: testcase-7497.yml -- name: check assertions inject_packages - assert: - that: - - install_ansible_lint is changed - - inject_pkgs_ansible_lint is changed - - '"ansible-lint" in inject_pkgs_ansible_lint.application' - - '"licenses" in inject_pkgs_ansible_lint.application["ansible-lint"]["injected"]' - - inject_pkgs_apps_ansible_lint is changed - - '"ansible-lint" in inject_pkgs_apps_ansible_lint.application' - - '"black" in inject_pkgs_apps_ansible_lint.application["ansible-lint"]["injected"]' - - uninstall_ansible_lint is changed +- name: Include testcase for issue 8656 + ansible.builtin.include_tasks: testcase-8656.yml -############################################################################## -- name: install jupyter - not working smoothly in freebsd - when: ansible_system != 'FreeBSD' +- name: install pipx + pip: + name: pipx>=1.7.0 + extra_args: --user + ignore_errors: true + register: pipx170_install + +- name: Recent features + when: + - pipx170_install is not failed + - pipx170_install is changed block: - - name: ensure application jupyter is uninstalled - community.general.pipx: - name: jupyter - state: absent + - name: Include testcase for PR 8793 --global + ansible.builtin.include_tasks: testcase-8793-global.yml - - name: install application jupyter - community.general.pipx: - name: jupyter - install_deps: true - register: install_jupyter + - name: Include testcase for PR 8809 install-all + ansible.builtin.include_tasks: testcase-8809-install-all.yml - - name: cleanup jupyter - community.general.pipx: - state: absent - name: jupyter + - name: Include testcase for PR 8809 pin + ansible.builtin.include_tasks: testcase-8809-pin.yml - - name: check assertions - assert: - that: - - install_jupyter is changed - - '"ipython" in install_jupyter.stdout' - -############################################################################## -- name: ensure /opt/pipx - ansible.builtin.file: - path: /opt/pipx - state: directory - mode: 0755 - -- name: install tox site-wide - community.general.pipx: - name: tox - state: latest - register: install_tox_sitewide - environment: - PIPX_HOME: /opt/pipx - PIPX_BIN_DIR: /usr/local/bin - -- name: stat /usr/local/bin/tox - ansible.builtin.stat: - path: /usr/local/bin/tox - register: usrlocaltox - -- name: check assertions - ansible.builtin.assert: - that: - - install_tox_sitewide is changed - - usrlocaltox.stat.exists - -############################################################################## -# Test for issue 7497 -- name: ensure application pyinstaller is uninstalled - community.general.pipx: - name: pyinstaller - state: absent - -- name: Install Python Package pyinstaller - community.general.pipx: - name: pyinstaller - state: present - system_site_packages: true - pip_args: "--no-cache-dir" - register: install_pyinstaller - -- name: cleanup pyinstaller - community.general.pipx: - name: pyinstaller - state: absent - -- name: check assertions - assert: - that: - - install_pyinstaller is changed + - name: Include testcase for PR 8809 injectpkg + ansible.builtin.include_tasks: testcase-8809-uninjectpkg.yml diff --git a/tests/integration/targets/pipx/tasks/testcase-7497.yml b/tests/integration/targets/pipx/tasks/testcase-7497.yml new file mode 100644 index 0000000000..938196ef59 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-7497.yml @@ -0,0 +1,27 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: ensure application pyinstaller is uninstalled + community.general.pipx: + name: pyinstaller + state: absent + +- name: Install Python Package pyinstaller + community.general.pipx: + name: pyinstaller + state: present + system_site_packages: true + pip_args: "--no-cache-dir" + register: install_pyinstaller + +- name: cleanup pyinstaller + community.general.pipx: + name: pyinstaller + state: absent + +- name: check assertions + assert: + that: + - install_pyinstaller is changed diff --git a/tests/integration/targets/pipx/tasks/testcase-8656.yml b/tests/integration/targets/pipx/tasks/testcase-8656.yml new file mode 100644 index 0000000000..10e99e846e --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8656.yml @@ -0,0 +1,35 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: ensure application conan2 is uninstalled + community.general.pipx: + name: conan2 + state: absent + +- name: Install Python Package conan with suffix 2 (conan2) + community.general.pipx: + name: conan + state: install + suffix: "2" + register: install_conan2 + +- name: Install Python Package conan with suffix 2 (conan2) again + community.general.pipx: + name: conan + state: install + suffix: "2" + register: install_conan2_again + +- name: cleanup conan2 + community.general.pipx: + name: conan2 + state: absent + +- name: check assertions + assert: + that: + - install_conan2 is changed + - "' - conan2' in install_conan2.stdout" + - install_conan2_again is not changed diff --git a/tests/integration/targets/pipx/tasks/testcase-8793-global.yml b/tests/integration/targets/pipx/tasks/testcase-8793-global.yml new file mode 100644 index 0000000000..7d3c871306 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8793-global.yml @@ -0,0 +1,58 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Remove global pipx dir + ansible.builtin.file: + path: /opt/pipx + state: absent + force: true + + - name: Create global pipx dir + ansible.builtin.file: + path: /opt/pipx + state: directory + mode: '0755' + + - name: Uninstall pycowsay + community.general.pipx: + state: uninstall + name: pycowsay + + - name: Uninstall pycowsay (global) + community.general.pipx: + state: uninstall + name: pycowsay + global: true + + - name: Run pycowsay (should fail) + ansible.builtin.command: pycowsay Moooooooo! + changed_when: false + ignore_errors: true + + - name: Install pycowsay (global) + community.general.pipx: + state: install + name: pycowsay + global: true + + - name: Run pycowsay (should succeed) + ansible.builtin.command: pycowsay Moooooooo! + changed_when: false + register: what_the_cow_said + + - name: Which cow? + ansible.builtin.command: which pycowsay + changed_when: false + register: which_cow + + - name: Assert Moooooooo + ansible.builtin.assert: + that: + - "'Moooooooo!' in what_the_cow_said.stdout" + - "'/usr/local/bin/pycowsay' in which_cow.stdout" diff --git a/tests/integration/targets/pipx/tasks/testcase-8809-installall.yml b/tests/integration/targets/pipx/tasks/testcase-8809-installall.yml new file mode 100644 index 0000000000..37816247c0 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8809-installall.yml @@ -0,0 +1,59 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Uninstall pycowsay and black + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_1 + + - name: Use install-all + community.general.pipx: + state: install-all + spec_metadata: spec.json + register: install_all + + - name: Run pycowsay (should succeed) + ansible.builtin.command: pycowsay Moooooooo! + changed_when: false + register: what_the_cow_said + + - name: Which cow? + ansible.builtin.command: which pycowsay + changed_when: false + register: which_cow + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_2 + + - name: Assert uninstall-all + ansible.builtin.assert: + that: + - uninstall_all_1 is not changed + - install_all is changed + - "'Moooooooo!' in what_the_cow_said.stdout" + - "'/usr/local/bin/pycowsay' in which_cow.stdout" + - uninstall_all_2 is changed diff --git a/tests/integration/targets/pipx/tasks/testcase-8809-pin.yml b/tests/integration/targets/pipx/tasks/testcase-8809-pin.yml new file mode 100644 index 0000000000..89e4bb9dc6 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8809-pin.yml @@ -0,0 +1,69 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Uninstall pycowsay and black + community.general.pipx: + state: uninstall + name: pycowsay + + # latest is 0.0.0.2 + - name: Install pycowsay 0.0.0.1 + community.general.pipx: + state: install + name: pycowsay + source: pycowsay==0.0.0.1 + + - name: Pin cowsay + community.general.pipx: + state: pin + name: pycowsay + register: pin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_1 + + - name: Unpin cowsay + community.general.pipx: + state: unpin + name: pycowsay + register: unpin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_2 + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_2 + + - name: Assert uninstall-all + ansible.builtin.assert: + that: + - pin_cow is changed + - cow_info_1 == "0.0.0.1" + - unpin_cow is changed + - cow_info_2 != "0.0.0.1" diff --git a/tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml b/tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml new file mode 100644 index 0000000000..89e4bb9dc6 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-8809-uninjectpkg.yml @@ -0,0 +1,69 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up environment + environment: + PATH: /usr/local/bin:{{ ansible_env.PATH }} + block: + - name: Uninstall pycowsay and black + community.general.pipx: + state: uninstall + name: pycowsay + + # latest is 0.0.0.2 + - name: Install pycowsay 0.0.0.1 + community.general.pipx: + state: install + name: pycowsay + source: pycowsay==0.0.0.1 + + - name: Pin cowsay + community.general.pipx: + state: pin + name: pycowsay + register: pin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_1 + + - name: Unpin cowsay + community.general.pipx: + state: unpin + name: pycowsay + register: unpin_cow + + - name: Upgrade pycowsay + community.general.pipx: + state: upgrade + name: pycowsay + + - name: Get pycowsay version + community.general.pipx_info: + name: pycowsay + register: cow_info_2 + + - name: Uninstall pycowsay and black (again) + community.general.pipx: + state: uninstall + name: "{{ item }}" + loop: + - black + - pycowsay + register: uninstall_all_2 + + - name: Assert uninstall-all + ansible.builtin.assert: + that: + - pin_cow is changed + - cow_info_1 == "0.0.0.1" + - unpin_cow is changed + - cow_info_2 != "0.0.0.1" diff --git a/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml b/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml new file mode 100644 index 0000000000..63d33ba92c --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-injectpkg.yml @@ -0,0 +1,49 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Ensure application pylint is uninstalled + community.general.pipx: + name: pylint + state: absent + +- name: Install application pylint + community.general.pipx: + name: pylint + register: install_pylint + +- name: Inject packages + community.general.pipx: + state: inject + name: pylint + inject_packages: + - licenses + register: inject_pkgs_pylint + +- name: Inject packages with apps + community.general.pipx: + state: inject + name: pylint + inject_packages: + - black + install_apps: true + register: inject_pkgs_apps_pylint + +- name: Cleanup pylint + community.general.pipx: + state: absent + name: pylint + register: uninstall_pylint + +- name: Check assertions inject_packages + assert: + that: + - install_pylint is changed + - inject_pkgs_pylint is changed + - '"pylint" in inject_pkgs_pylint.application' + - '"licenses" in inject_pkgs_pylint.application["pylint"]["injected"]' + - inject_pkgs_apps_pylint is changed + - '"pylint" in inject_pkgs_apps_pylint.application' + - '"black" in inject_pkgs_apps_pylint.application["pylint"]["injected"]' + - uninstall_pylint is changed diff --git a/tests/integration/targets/pipx/tasks/testcase-jupyter.yml b/tests/integration/targets/pipx/tasks/testcase-jupyter.yml new file mode 100644 index 0000000000..e4b5d48dd5 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-jupyter.yml @@ -0,0 +1,28 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: install jupyter + block: + - name: ensure application mkdocs is uninstalled + community.general.pipx: + name: mkdocs + state: absent + + - name: install application mkdocs + community.general.pipx: + name: mkdocs + install_deps: true + register: install_mkdocs + + - name: cleanup mkdocs + community.general.pipx: + state: absent + name: mkdocs + + - name: check assertions + assert: + that: + - install_mkdocs is changed + - '"markdown_py" in install_mkdocs.stdout' diff --git a/tests/integration/targets/pipx/tasks/testcase-oldsitewide.yml b/tests/integration/targets/pipx/tasks/testcase-oldsitewide.yml new file mode 100644 index 0000000000..1db3e60406 --- /dev/null +++ b/tests/integration/targets/pipx/tasks/testcase-oldsitewide.yml @@ -0,0 +1,40 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Ensure /opt/pipx + ansible.builtin.file: + path: /opt/pipx + state: directory + mode: 0755 + +- name: Install tox site-wide + community.general.pipx: + name: tox + state: latest + register: install_tox_sitewide + environment: + PIPX_HOME: /opt/pipx + PIPX_BIN_DIR: /usr/local/bin + +- name: stat /usr/local/bin/tox + ansible.builtin.stat: + path: /usr/local/bin/tox + register: usrlocaltox + +- name: Uninstall tox site-wide + community.general.pipx: + name: tox + state: uninstall + register: uninstall_tox_sitewide + environment: + PIPX_HOME: /opt/pipx + PIPX_BIN_DIR: /usr/local/bin + +- name: check assertions + ansible.builtin.assert: + that: + - install_tox_sitewide is changed + - usrlocaltox.stat.exists + - uninstall_tox_sitewide is changed diff --git a/tests/integration/targets/pipx_info/aliases b/tests/integration/targets/pipx_info/aliases index e262b485a6..a28278bbc1 100644 --- a/tests/integration/targets/pipx_info/aliases +++ b/tests/integration/targets/pipx_info/aliases @@ -6,4 +6,3 @@ azp/posix/3 destructive skip/python2 skip/python3.5 -disabled # TODO diff --git a/tests/integration/targets/pipx_info/tasks/main.yml b/tests/integration/targets/pipx_info/tasks/main.yml index 0a01f0af9c..e3de105d6f 100644 --- a/tests/integration/targets/pipx_info/tasks/main.yml +++ b/tests/integration/targets/pipx_info/tasks/main.yml @@ -68,7 +68,7 @@ apps: - name: tox source: tox==3.24.0 - - name: ansible-lint + - name: pylint inject_packages: - licenses @@ -81,7 +81,7 @@ - name: install applications community.general.pipx: name: "{{ item.name }}" - source: "{{ item.source|default(omit) }}" + source: "{{ item.source | default(omit) }}" loop: "{{ apps }}" - name: inject packages @@ -102,9 +102,9 @@ include_injected: true register: info2_all_deps -- name: retrieve application ansible-lint +- name: retrieve application pylint community.general.pipx_info: - name: ansible-lint + name: pylint include_deps: true include_injected: true register: info2_lint @@ -131,10 +131,10 @@ - "'injected' in all_apps_deps[0]" - "'licenses' in all_apps_deps[0].injected" - - lint|length == 1 + - lint | length == 1 - all_apps_deps|length == 2 - lint[0] == all_apps_deps[0] vars: all_apps: "{{ info2_all.application|sort(attribute='name') }}" - all_apps_deps: "{{ info2_all_deps.application|sort(attribute='name') }}" - lint: "{{ info2_lint.application|sort(attribute='name') }}" + all_apps_deps: "{{ info2_all_deps.application | sort(attribute='name') }}" + lint: "{{ info2_lint.application | sort(attribute='name') }}" diff --git a/tests/integration/targets/pkgng/tasks/freebsd.yml b/tests/integration/targets/pkgng/tasks/freebsd.yml index 9d4ecf8bb2..e69d26c20d 100644 --- a/tests/integration/targets/pkgng/tasks/freebsd.yml +++ b/tests/integration/targets/pkgng/tasks/freebsd.yml @@ -521,12 +521,15 @@ # NOTE: FreeBSD 14.0 fails to update the package catalogue for unknown reasons (someone with FreeBSD # knowledge has to take a look) # + # NOTE: FreeBSD 14.1 fails to update the package catalogue for unknown reasons (someone with FreeBSD + # knowledge has to take a look) + # # See also # https://github.com/ansible-collections/community.general/issues/5795 when: >- (ansible_distribution_version is version('12.01', '>=') and ansible_distribution_version is version('12.3', '<')) or (ansible_distribution_version is version('13.4', '>=') and ansible_distribution_version is version('14.0', '<')) - or ansible_distribution_version is version('14.1', '>=') + or ansible_distribution_version is version('14.2', '>=') block: - name: Setup testjail include_tasks: setup-testjail.yml diff --git a/tests/integration/targets/pkgng/tasks/install_single_package.yml b/tests/integration/targets/pkgng/tasks/install_single_package.yml index 5ba529af35..7f0886af8b 100644 --- a/tests/integration/targets/pkgng/tasks/install_single_package.yml +++ b/tests/integration/targets/pkgng/tasks/install_single_package.yml @@ -40,6 +40,16 @@ get_mime: false register: pkgng_install_stat_after +- name: Upgrade package (orig, no globs) + pkgng: + name: '{{ pkgng_test_pkg_category }}/{{ pkgng_test_pkg_name }}' + state: latest + use_globs: false + jail: '{{ pkgng_test_jail | default(omit) }}' + chroot: '{{ pkgng_test_chroot | default(omit) }}' + rootdir: '{{ pkgng_test_rootdir | default(omit) }}' + register: pkgng_upgrade_orig_noglobs + - name: Remove test package (if requested) pkgng: <<: *pkgng_install_params @@ -56,3 +66,4 @@ - not pkgng_install_idempotent_cached.stdout is match("Updating \w+ repository catalogue\.\.\.") - pkgng_install_stat_after.stat.exists - pkgng_install_stat_after.stat.executable + - pkgng_upgrade_orig_noglobs is not changed diff --git a/tests/integration/targets/setup_java_keytool/vars/Ubuntu-24.yml b/tests/integration/targets/setup_java_keytool/vars/Ubuntu-24.yml new file mode 100644 index 0000000000..addf344fe2 --- /dev/null +++ b/tests/integration/targets/setup_java_keytool/vars/Ubuntu-24.yml @@ -0,0 +1,8 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +keytool_package_names: + - ca-certificates-java + - openjdk-21-jre-headless diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/alpine.yml b/tests/integration/targets/setup_os_pkg_name/tasks/alpine.yml new file mode 100644 index 0000000000..bb17b5e5f1 --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/alpine.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Update OS Package name fact (alpine) + ansible.builtin.set_fact: + os_package_name: "{{ os_package_name | combine(specific_package_names) }}" + vars: + specific_package_names: + virtualenv: py3-virtualenv diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/archlinux.yml b/tests/integration/targets/setup_os_pkg_name/tasks/archlinux.yml new file mode 100644 index 0000000000..bb98583506 --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/archlinux.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Update OS Package name fact (archlinux) + ansible.builtin.set_fact: + os_package_name: "{{ os_package_name | combine(specific_package_names) }}" + vars: + specific_package_names: + virtualenv: python-virtualenv diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/debian.yml b/tests/integration/targets/setup_os_pkg_name/tasks/debian.yml new file mode 100644 index 0000000000..6a20de1eeb --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/debian.yml @@ -0,0 +1,10 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Update OS Package name fact (debian) + ansible.builtin.set_fact: + os_package_name: "{{ os_package_name | combine(specific_package_names) }}" + vars: + specific_package_names: {} diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/default.yml b/tests/integration/targets/setup_os_pkg_name/tasks/default.yml new file mode 100644 index 0000000000..977d690437 --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/default.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Update OS Package name fact (default) + ansible.builtin.set_fact: + os_package_name: "{{ os_package_name | combine(specific_package_names) }}" + vars: + specific_package_names: + virtualenv: virtualenv diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/main.yml b/tests/integration/targets/setup_os_pkg_name/tasks/main.yml new file mode 100644 index 0000000000..91066cf53c --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/main.yml @@ -0,0 +1,26 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Make sure we have the ansible_os_family and ansible_distribution_version facts + ansible.builtin.setup: + gather_subset: distribution + when: ansible_facts == {} + +- name: Create OS Package name fact + ansible.builtin.set_fact: + os_package_name: {} + +- name: Include the files setting the package names + ansible.builtin.include_tasks: "{{ file }}" + loop_control: + loop_var: file + loop: + - "default.yml" + - "{{ ansible_os_family | lower }}.yml" diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/redhat.yml b/tests/integration/targets/setup_os_pkg_name/tasks/redhat.yml new file mode 100644 index 0000000000..022de8b961 --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/redhat.yml @@ -0,0 +1,10 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Update OS Package name fact (redhat) + ansible.builtin.set_fact: + os_package_name: "{{ os_package_name | combine(specific_package_names) }}" + vars: + specific_package_names: {} diff --git a/tests/integration/targets/setup_os_pkg_name/tasks/suse.yml b/tests/integration/targets/setup_os_pkg_name/tasks/suse.yml new file mode 100644 index 0000000000..db2b0a1fa2 --- /dev/null +++ b/tests/integration/targets/setup_os_pkg_name/tasks/suse.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Update OS Package name fact (suse) + ansible.builtin.set_fact: + os_package_name: "{{ os_package_name | combine(specific_package_names) }}" + vars: + specific_package_names: + virtualenv: python3-virtualenv diff --git a/tests/integration/targets/setup_pkg_mgr/tasks/main.yml b/tests/integration/targets/setup_pkg_mgr/tasks/main.yml index 5bff53b3b1..91f406d861 100644 --- a/tests/integration/targets/setup_pkg_mgr/tasks/main.yml +++ b/tests/integration/targets/setup_pkg_mgr/tasks/main.yml @@ -26,6 +26,12 @@ cacheable: true when: ansible_os_family == "Archlinux" +- shell: + cmd: | + sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/*.repo + sed -i 's%#baseurl=http://mirror.centos.org/%baseurl=https://vault.centos.org/%g' /etc/yum.repos.d/*.repo + when: ansible_distribution in 'CentOS' and ansible_distribution_major_version == '7' + - shell: cmd: | sed -i 's/mirrorlist/#mirrorlist/g' /etc/yum.repos.d/CentOS-Linux-*.repo diff --git a/tests/integration/targets/setup_postgresql_db/vars/Ubuntu-24-py3.yml b/tests/integration/targets/setup_postgresql_db/vars/Ubuntu-24-py3.yml new file mode 100644 index 0000000000..702bd9a5d1 --- /dev/null +++ b/tests/integration/targets/setup_postgresql_db/vars/Ubuntu-24-py3.yml @@ -0,0 +1,13 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +postgresql_packages: + - "postgresql" + - "postgresql-common" + - "python3-psycopg2" + +pg_hba_location: "/etc/postgresql/16/main/pg_hba.conf" +pg_dir: "/var/lib/postgresql/16/main" +pg_ver: 16 diff --git a/tests/integration/targets/setup_snap/tasks/D-RedHat-9.4.yml b/tests/integration/targets/setup_snap/tasks/D-RedHat-9.4.yml new file mode 120000 index 0000000000..0b06951496 --- /dev/null +++ b/tests/integration/targets/setup_snap/tasks/D-RedHat-9.4.yml @@ -0,0 +1 @@ +nothing.yml \ No newline at end of file diff --git a/tests/integration/targets/snap/tasks/main.yml b/tests/integration/targets/snap/tasks/main.yml index 2a683617ae..a2d8698d0f 100644 --- a/tests/integration/targets/snap/tasks/main.yml +++ b/tests/integration/targets/snap/tasks/main.yml @@ -13,10 +13,9 @@ block: - name: Include test ansible.builtin.include_tasks: test.yml - # TODO: Find better package to install from a channel - microk8s installation takes multiple minutes, and even removal takes one minute! - # - name: Include test_channel - # ansible.builtin.include_tasks: test_channel.yml - # TODO: Find bettter package to download and install from sources - cider 1.6.0 takes over 35 seconds to install + - name: Include test_channel + ansible.builtin.include_tasks: test_channel.yml + # TODO: Find better package to download and install from sources - cider 1.6.0 takes over 35 seconds to install # - name: Include test_dangerous # ansible.builtin.include_tasks: test_dangerous.yml - name: Include test_3dash diff --git a/tests/integration/targets/snap/tasks/test_channel.yml b/tests/integration/targets/snap/tasks/test_channel.yml index e9eb19c897..3537357615 100644 --- a/tests/integration/targets/snap/tasks/test_channel.yml +++ b/tests/integration/targets/snap/tasks/test_channel.yml @@ -5,47 +5,44 @@ # NOTE This is currently disabled for performance reasons! -- name: Make sure package is not installed (microk8s) +- name: Make sure package is not installed (wisdom) community.general.snap: - name: microk8s + name: wisdom state: absent # Test for https://github.com/ansible-collections/community.general/issues/1606 -- name: Install package (microk8s) +- name: Install package (wisdom) community.general.snap: - name: microk8s - classic: true + name: wisdom state: present - register: install_microk8s + register: install_wisdom -- name: Install package with channel (microk8s) +- name: Install package with channel (wisdom) community.general.snap: - name: microk8s - classic: true - channel: 1.20/stable + name: wisdom state: present - register: install_microk8s_chan + channel: latest/edge + register: install_wisdom_chan -- name: Install package with channel (microk8s) again +- name: Install package with channel (wisdom) again community.general.snap: - name: microk8s - classic: true - channel: 1.20/stable + name: wisdom state: present - register: install_microk8s_chan_again + channel: latest/edge + register: install_wisdom_chan_again -- name: Remove package (microk8s) +- name: Remove package (wisdom) community.general.snap: - name: microk8s + name: wisdom state: absent - register: remove_microk8s + register: remove_wisdom - assert: that: - - install_microk8s is changed - - install_microk8s_chan is changed - - install_microk8s_chan_again is not changed - - remove_microk8s is changed + - install_wisdom is changed + - install_wisdom_chan is changed + - install_wisdom_chan_again is not changed + - remove_wisdom is changed - name: Install package (shellcheck) community.general.snap: diff --git a/tests/integration/targets/test_ansible_type/aliases b/tests/integration/targets/test_ansible_type/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/test_ansible_type/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/test_ansible_type/tasks/main.yml b/tests/integration/targets/test_ansible_type/tasks/main.yml new file mode 100644 index 0000000000..c890c11901 --- /dev/null +++ b/tests/integration/targets/test_ansible_type/tasks/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Integration tests + import_tasks: tasks.yml diff --git a/tests/integration/targets/test_ansible_type/tasks/tasks.yml b/tests/integration/targets/test_ansible_type/tasks/tasks.yml new file mode 100644 index 0000000000..261256c0d4 --- /dev/null +++ b/tests/integration/targets/test_ansible_type/tasks/tasks.yml @@ -0,0 +1,248 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Substitution converts str to AnsibleUnicode +# ------------------------------------------- + +- name: String. AnsibleUnicode. + assert: + that: data is community.general.ansible_type(dtype) + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: "abc" + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: 'AnsibleUnicode' + +- name: String. AnsibleUnicode alias str. + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: "abc" + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: 'str' + +- name: List. All items are AnsibleUnicode. + assert: + that: data is community.general.ansible_type(dtype) + success_msg: '["a", "b", "c"] is {{ dtype }}' + fail_msg: '["a", "b", "c"] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: ["a", "b", "c"] + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: 'list[AnsibleUnicode]' + +- name: Dictionary. All keys are AnsibleUnicode. All values are AnsibleUnicode. + assert: + that: data is community.general.ansible_type(dtype) + success_msg: '{"a": "foo", "b": "bar", "c": "baz"} is {{ dtype }}' + fail_msg: '{"a": "foo", "b": "bar", "c": "baz"} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: {"a": "foo", "b": "bar", "c": "baz"} + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: 'dict[AnsibleUnicode, AnsibleUnicode]' + +# No substitution and no alias. Type of strings is str +# ---------------------------------------------------- + +- name: String + assert: + that: '"abc" is community.general.ansible_type(dtype)' + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ "abc" | community.general.reveal_ansible_type }}' + dtype: str + +- name: Integer + assert: + that: '123 is community.general.ansible_type(dtype)' + success_msg: '123 is {{ dtype }}' + fail_msg: '123 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ 123 | community.general.reveal_ansible_type }}' + dtype: int + +- name: Float + assert: + that: '123.45 is community.general.ansible_type(dtype)' + success_msg: '123.45 is {{ dtype }}' + fail_msg: '123.45 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ 123.45 | community.general.reveal_ansible_type }}' + dtype: float + +- name: Boolean + assert: + that: 'true is community.general.ansible_type(dtype)' + success_msg: 'true is {{ dtype }}' + fail_msg: 'true is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ true | community.general.reveal_ansible_type }}' + dtype: bool + +- name: List. All items are strings. + assert: + that: '["a", "b", "c"] is community.general.ansible_type(dtype)' + success_msg: '["a", "b", "c"] is {{ dtype }}' + fail_msg: '["a", "b", "c"] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ ["a", "b", "c"] | community.general.reveal_ansible_type }}' + dtype: list[str] + +- name: List of dictionaries. + assert: + that: '[{"a": 1}, {"b": 2}] is community.general.ansible_type(dtype)' + success_msg: '[{"a": 1}, {"b": 2}] is {{ dtype }}' + fail_msg: '[{"a": 1}, {"b": 2}] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ [{"a": 1}, {"b": 2}] | community.general.reveal_ansible_type }}' + dtype: list[dict] + +- name: Dictionary. All keys are strings. All values are integers. + assert: + that: '{"a": 1} is community.general.ansible_type(dtype)' + success_msg: '{"a": 1} is {{ dtype }}' + fail_msg: '{"a": 1} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ {"a": 1} | community.general.reveal_ansible_type }}' + dtype: dict[str, int] + +- name: Dictionary. All keys are strings. All values are integers. + assert: + that: '{"a": 1, "b": 2} is community.general.ansible_type(dtype)' + success_msg: '{"a": 1, "b": 2} is {{ dtype }}' + fail_msg: '{"a": 1, "b": 2} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + result: '{{ {"a": 1, "b": 2} | community.general.reveal_ansible_type }}' + dtype: dict[str, int] + +# Type of strings is AnsibleUnicode or str +# ---------------------------------------- + +- name: Dictionary. The keys are integers or strings. All values are strings. + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '{"1": "a", "b": "b"} is {{ dtype }}' + fail_msg: '{"1": "a", "b": "b"} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: {1: 'a', 'b': 'b'} + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: dict[int|str, str] + +- name: Dictionary. All keys are integers. All values are keys. + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '{"1": "a", "2": "b"} is {{ dtype }}' + fail_msg: '{"1": "a", "2": "b"} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: {1: 'a', 2: 'b'} + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: dict[int, str] + +- name: Dictionary. All keys are strings. Multiple types values. + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '{"a": 1, "b": 1.1, "c": "abc", "d": true, "e": ["x", "y", "z"], "f": {"x": 1, "y": 2}} is {{ dtype }}' + fail_msg: '{"a": 1, "b": 1.1, "c": "abc", "d": true, "e": ["x", "y", "z"], "f": {"x": 1, "y": 2}} is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: {'a': 1, 'b': 1.1, 'c': 'abc', 'd': True, 'e': ['x', 'y', 'z'], 'f': {'x': 1, 'y': 2}} + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: dict[str, bool|dict|float|int|list|str] + +- name: List. Multiple types items. + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '[1, 2, 1.1, "abc", true, ["x", "y", "z"], {"x": 1, "y": 2}] is {{ dtype }}' + fail_msg: '[1, 2, 1.1, "abc", true, ["x", "y", "z"], {"x": 1, "y": 2}] is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"AnsibleUnicode": "str"} + data: [1, 2, 1.1, 'abc', True, ['x', 'y', 'z'], {'x': 1, 'y': 2}] + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: list[bool|dict|float|int|list|str] + +# Option dtype is list +# -------------------- + +- name: AnsibleUnicode or str + assert: + that: data is community.general.ansible_type(dtype) + success_msg: '"abc" is {{ dtype }}' + fail_msg: '"abc" is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: abc + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: ['AnsibleUnicode', 'str'] + +- name: float or int + assert: + that: data is community.general.ansible_type(dtype) + success_msg: '123 is {{ dtype }}' + fail_msg: '123 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: 123 + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: ['float', 'int'] + +- name: float or int + assert: + that: data is community.general.ansible_type(dtype) + success_msg: '123.45 is {{ dtype }}' + fail_msg: '123.45 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + data: 123.45 + result: '{{ data | community.general.reveal_ansible_type }}' + dtype: ['float', 'int'] + +# Multiple alias +# -------------- + +- name: int alias number + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '123 is {{ dtype }}' + fail_msg: '123 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"int": "number", "float": "number"} + data: 123 + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: number + +- name: float alias number + assert: + that: data is community.general.ansible_type(dtype, alias) + success_msg: '123.45 is {{ dtype }}' + fail_msg: '123.45 is {{ result }}' + quiet: '{{ quiet_test | d(true) | bool }}' + vars: + alias: {"int": "number", "float": "number"} + data: 123.45 + result: '{{ data | community.general.reveal_ansible_type(alias) }}' + dtype: number diff --git a/tests/integration/targets/timezone/tasks/main.yml b/tests/integration/targets/timezone/tasks/main.yml index 721341592a..475f22447d 100644 --- a/tests/integration/targets/timezone/tasks/main.yml +++ b/tests/integration/targets/timezone/tasks/main.yml @@ -60,6 +60,14 @@ state: present when: ansible_distribution == 'Alpine' +- name: make sure hwclock is installed in Ubuntu 24.04 + package: + name: util-linux-extra + state: present + when: + - ansible_distribution == 'Ubuntu' + - ansible_facts.distribution_major_version is version('24', '>=') + - name: make sure the dbus service is started under systemd systemd: name: dbus diff --git a/tests/integration/targets/ufw/aliases b/tests/integration/targets/ufw/aliases index 209a1153e4..b1dbfd2eb1 100644 --- a/tests/integration/targets/ufw/aliases +++ b/tests/integration/targets/ufw/aliases @@ -13,6 +13,7 @@ skip/rhel9.0 # FIXME skip/rhel9.1 # FIXME skip/rhel9.2 # FIXME skip/rhel9.3 # FIXME +skip/rhel9.4 # FIXME skip/docker needs/root needs/target/setup_epel diff --git a/tests/sanity/extra/botmeta.py b/tests/sanity/extra/botmeta.py index 459d3ba14d..d7828ebabb 100755 --- a/tests/sanity/extra/botmeta.py +++ b/tests/sanity/extra/botmeta.py @@ -190,7 +190,7 @@ def main(): try: for file, filedata in (botmeta.get('files') or {}).items(): file = convert_macros(file, macros) - filedata = dict((k, convert_macros(v, macros)) for k, v in filedata.items()) + filedata = {k: convert_macros(v, macros) for k, v in filedata.items()} files[file] = filedata for k, v in filedata.items(): if k in LIST_ENTRIES: diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt index 0665ddc1a1..6f6495dd17 100644 --- a/tests/sanity/ignore-2.13.txt +++ b/tests/sanity/ignore-2.13.txt @@ -1,4 +1,6 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen +plugins/callback/timestamp.py validate-modules:invalid-documentation +plugins/callback/yaml.py validate-modules:invalid-documentation plugins/lookup/etcd.py validate-modules:invalid-documentation plugins/lookup/etcd3.py validate-modules:invalid-documentation plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice @@ -6,9 +8,6 @@ plugins/modules/iptables_state.py validate-modules:undocumented-parameter plugins/modules/lxc_container.py validate-modules:use-run-command-not-popen plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice plugins/modules/parted.py validate-modules:parameter-state-invalid-choice -plugins/modules/rax_files_objects.py use-argspec-type-path # module deprecated - removed in 9.0.0 -plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice # module deprecated - removed in 9.0.0 -plugins/modules/rax.py use-argspec-type-path # module deprecated - removed in 9.0.0 plugins/modules/read_csv.py validate-modules:invalid-documentation plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/xfconf.py validate-modules:return-syntax-error diff --git a/tests/sanity/ignore-2.14.txt b/tests/sanity/ignore-2.14.txt index fed147e446..24d7521036 100644 --- a/tests/sanity/ignore-2.14.txt +++ b/tests/sanity/ignore-2.14.txt @@ -1,4 +1,6 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen +plugins/callback/timestamp.py validate-modules:invalid-documentation +plugins/callback/yaml.py validate-modules:invalid-documentation plugins/lookup/etcd.py validate-modules:invalid-documentation plugins/lookup/etcd3.py validate-modules:invalid-documentation plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice @@ -7,9 +9,6 @@ plugins/modules/iptables_state.py validate-modules:undocumented-parameter plugins/modules/lxc_container.py validate-modules:use-run-command-not-popen plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice plugins/modules/parted.py validate-modules:parameter-state-invalid-choice -plugins/modules/rax_files_objects.py use-argspec-type-path # module deprecated - removed in 9.0.0 -plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice # module deprecated - removed in 9.0.0 -plugins/modules/rax.py use-argspec-type-path # module deprecated - removed in 9.0.0 plugins/modules/read_csv.py validate-modules:invalid-documentation plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt' diff --git a/tests/sanity/ignore-2.15.txt b/tests/sanity/ignore-2.15.txt index d4c92c4d9b..667c6cee4d 100644 --- a/tests/sanity/ignore-2.15.txt +++ b/tests/sanity/ignore-2.15.txt @@ -5,9 +5,6 @@ plugins/modules/iptables_state.py validate-modules:undocumented-parameter plugins/modules/lxc_container.py validate-modules:use-run-command-not-popen plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice plugins/modules/parted.py validate-modules:parameter-state-invalid-choice -plugins/modules/rax_files_objects.py use-argspec-type-path # module deprecated - removed in 9.0.0 -plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice # module deprecated - removed in 9.0.0 -plugins/modules/rax.py use-argspec-type-path # module deprecated - removed in 9.0.0 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt' plugins/modules/xfconf.py validate-modules:return-syntax-error diff --git a/tests/sanity/ignore-2.16.txt b/tests/sanity/ignore-2.16.txt index 397c6d9865..f6b058ec69 100644 --- a/tests/sanity/ignore-2.16.txt +++ b/tests/sanity/ignore-2.16.txt @@ -5,9 +5,6 @@ plugins/modules/iptables_state.py validate-modules:undocumented-parameter plugins/modules/lxc_container.py validate-modules:use-run-command-not-popen plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice plugins/modules/parted.py validate-modules:parameter-state-invalid-choice -plugins/modules/rax_files_objects.py use-argspec-type-path # module deprecated - removed in 9.0.0 -plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice # module deprecated - removed in 9.0.0 -plugins/modules/rax.py use-argspec-type-path # module deprecated - removed in 9.0.0 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt' plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt' diff --git a/tests/sanity/ignore-2.17.txt b/tests/sanity/ignore-2.17.txt index d75aaeac27..806c4c5fcf 100644 --- a/tests/sanity/ignore-2.17.txt +++ b/tests/sanity/ignore-2.17.txt @@ -5,13 +5,11 @@ plugins/modules/iptables_state.py validate-modules:undocumented-parameter plugins/modules/lxc_container.py validate-modules:use-run-command-not-popen plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice plugins/modules/parted.py validate-modules:parameter-state-invalid-choice -plugins/modules/rax_files_objects.py use-argspec-type-path # module deprecated - removed in 9.0.0 -plugins/modules/rax_files.py validate-modules:parameter-state-invalid-choice # module deprecated - removed in 9.0.0 -plugins/modules/rax.py use-argspec-type-path # module deprecated - removed in 9.0.0 plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt' plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt' plugins/modules/xfconf.py validate-modules:return-syntax-error plugins/module_utils/univention_umc.py pylint:use-yield-from # suggested construct does not work with Python 2 tests/unit/compat/mock.py pylint:use-yield-from # suggested construct does not work with Python 2 +tests/unit/plugins/modules/helper.py pylint:use-yield-from # suggested construct does not work with Python 2 tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes diff --git a/tests/sanity/ignore-2.18.txt b/tests/sanity/ignore-2.18.txt new file mode 100644 index 0000000000..806c4c5fcf --- /dev/null +++ b/tests/sanity/ignore-2.18.txt @@ -0,0 +1,15 @@ +plugins/modules/consul_session.py validate-modules:parameter-state-invalid-choice +plugins/modules/homectl.py import-3.11 # Uses deprecated stdlib library 'crypt' +plugins/modules/homectl.py import-3.12 # Uses deprecated stdlib library 'crypt' +plugins/modules/iptables_state.py validate-modules:undocumented-parameter # params _back and _timeout used by action plugin +plugins/modules/lxc_container.py validate-modules:use-run-command-not-popen +plugins/modules/osx_defaults.py validate-modules:parameter-state-invalid-choice +plugins/modules/parted.py validate-modules:parameter-state-invalid-choice +plugins/modules/rhevm.py validate-modules:parameter-state-invalid-choice +plugins/modules/udm_user.py import-3.11 # Uses deprecated stdlib library 'crypt' +plugins/modules/udm_user.py import-3.12 # Uses deprecated stdlib library 'crypt' +plugins/modules/xfconf.py validate-modules:return-syntax-error +plugins/module_utils/univention_umc.py pylint:use-yield-from # suggested construct does not work with Python 2 +tests/unit/compat/mock.py pylint:use-yield-from # suggested construct does not work with Python 2 +tests/unit/plugins/modules/helper.py pylint:use-yield-from # suggested construct does not work with Python 2 +tests/unit/plugins/modules/test_gio_mime.yaml no-smart-quotes diff --git a/tests/sanity/ignore-2.18.txt.license b/tests/sanity/ignore-2.18.txt.license new file mode 100644 index 0000000000..edff8c7685 --- /dev/null +++ b/tests/sanity/ignore-2.18.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/tests/unit/plugins/become/test_run0.py b/tests/unit/plugins/become/test_run0.py new file mode 100644 index 0000000000..7507c556e8 --- /dev/null +++ b/tests/unit/plugins/become/test_run0.py @@ -0,0 +1,64 @@ +# Copyright (c) 2024 Ansible Project +# +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import re + +from ansible import context + +from .helper import call_become_plugin + + +def test_run0_basic(mocker, parser, reset_cli_args): + options = parser.parse_args([]) + context._init_global_context(options) + + default_cmd = "/bin/foo" + default_exe = "/bin/sh" + run0_exe = "run0" + + success = "BECOME-SUCCESS-.+?" + + task = { + "become_method": "community.general.run0", + } + var_options = {} + cmd = call_become_plugin(task, var_options, cmd=default_cmd, executable=default_exe) + assert ( + re.match( + f"{run0_exe} --user=root {default_exe} -c 'echo {success}; {default_cmd}'", + cmd, + ) + is not None + ) + + +def test_run0_flags(mocker, parser, reset_cli_args): + options = parser.parse_args([]) + context._init_global_context(options) + + default_cmd = "/bin/foo" + default_exe = "/bin/sh" + run0_exe = "run0" + run0_flags = "--nice=15" + + success = "BECOME-SUCCESS-.+?" + + task = { + "become_method": "community.general.run0", + "become_flags": run0_flags, + } + var_options = {} + cmd = call_become_plugin(task, var_options, cmd=default_cmd, executable=default_exe) + assert ( + re.match( + f"{run0_exe} --user=root --nice=15 {default_exe} -c 'echo {success}; {default_cmd}'", + cmd, + ) + is not None + ) diff --git a/tests/unit/plugins/callback/test_loganalytics.py b/tests/unit/plugins/callback/test_loganalytics.py index 17932ed5fa..4d7c2c9db9 100644 --- a/tests/unit/plugins/callback/test_loganalytics.py +++ b/tests/unit/plugins/callback/test_loganalytics.py @@ -9,8 +9,8 @@ from ansible.executor.task_result import TaskResult from ansible_collections.community.general.tests.unit.compat import unittest from ansible_collections.community.general.tests.unit.compat.mock import patch, Mock from ansible_collections.community.general.plugins.callback.loganalytics import AzureLogAnalyticsSource -from datetime import datetime +from datetime import datetime import json import sys @@ -32,10 +32,10 @@ class TestAzureLogAnalytics(unittest.TestCase): if sys.version_info < (3, 2): self.assertRegex = self.assertRegexpMatches - @patch('ansible_collections.community.general.plugins.callback.loganalytics.datetime') + @patch('ansible_collections.community.general.plugins.callback.loganalytics.now') @patch('ansible_collections.community.general.plugins.callback.loganalytics.open_url') - def test_overall(self, open_url_mock, mock_datetime): - mock_datetime.utcnow.return_value = datetime(2020, 12, 1) + def test_overall(self, open_url_mock, mock_now): + mock_now.return_value = datetime(2020, 12, 1) result = TaskResult(host=self.mock_host, task=self.mock_task, return_data={}, task_fields=self.task_fields) self.loganalytics.send_event(workspace_id='01234567-0123-0123-0123-01234567890a', @@ -52,10 +52,10 @@ class TestAzureLogAnalytics(unittest.TestCase): self.assertEqual(sent_data['event']['uuid'], 'myuuid') self.assertEqual(args[0], 'https://01234567-0123-0123-0123-01234567890a.ods.opinsights.azure.com/api/logs?api-version=2016-04-01') - @patch('ansible_collections.community.general.plugins.callback.loganalytics.datetime') + @patch('ansible_collections.community.general.plugins.callback.loganalytics.now') @patch('ansible_collections.community.general.plugins.callback.loganalytics.open_url') - def test_auth_headers(self, open_url_mock, mock_datetime): - mock_datetime.utcnow.return_value = datetime(2020, 12, 1) + def test_auth_headers(self, open_url_mock, mock_now): + mock_now.return_value = datetime(2020, 12, 1) result = TaskResult(host=self.mock_host, task=self.mock_task, return_data={}, task_fields=self.task_fields) self.loganalytics.send_event(workspace_id='01234567-0123-0123-0123-01234567890a', diff --git a/tests/unit/plugins/callback/test_splunk.py b/tests/unit/plugins/callback/test_splunk.py index ddcdae24c7..c09540fc00 100644 --- a/tests/unit/plugins/callback/test_splunk.py +++ b/tests/unit/plugins/callback/test_splunk.py @@ -27,10 +27,10 @@ class TestSplunkClient(unittest.TestCase): self.mock_host = Mock('MockHost') self.mock_host.name = 'myhost' - @patch('ansible_collections.community.general.plugins.callback.splunk.datetime') + @patch('ansible_collections.community.general.plugins.callback.splunk.now') @patch('ansible_collections.community.general.plugins.callback.splunk.open_url') - def test_timestamp_with_milliseconds(self, open_url_mock, mock_datetime): - mock_datetime.utcnow.return_value = datetime(2020, 12, 1) + def test_timestamp_with_milliseconds(self, open_url_mock, mock_now): + mock_now.return_value = datetime(2020, 12, 1) result = TaskResult(host=self.mock_host, task=self.mock_task, return_data={}, task_fields=self.task_fields) self.splunk.send_event( @@ -45,10 +45,10 @@ class TestSplunkClient(unittest.TestCase): self.assertEqual(sent_data['event']['host'], 'my-host') self.assertEqual(sent_data['event']['ip_address'], '1.2.3.4') - @patch('ansible_collections.community.general.plugins.callback.splunk.datetime') + @patch('ansible_collections.community.general.plugins.callback.splunk.now') @patch('ansible_collections.community.general.plugins.callback.splunk.open_url') - def test_timestamp_without_milliseconds(self, open_url_mock, mock_datetime): - mock_datetime.utcnow.return_value = datetime(2020, 12, 1) + def test_timestamp_without_milliseconds(self, open_url_mock, mock_now): + mock_now.return_value = datetime(2020, 12, 1) result = TaskResult(host=self.mock_host, task=self.mock_task, return_data={}, task_fields=self.task_fields) self.splunk.send_event( diff --git a/tests/unit/plugins/inventory/test_proxmox.py b/tests/unit/plugins/inventory/test_proxmox.py index ea6c84bcda..b8358df226 100644 --- a/tests/unit/plugins/inventory/test_proxmox.py +++ b/tests/unit/plugins/inventory/test_proxmox.py @@ -37,7 +37,7 @@ def get_auth(): # NOTE: when updating/adding replies to this function, # be sure to only add only the _contents_ of the 'data' dict in the API reply -def get_json(url): +def get_json(url, ignore_errors=None): if url == "https://localhost:8006/api2/json/nodes": # _get_nodes return [{"type": "node", diff --git a/tests/unit/plugins/inventory/test_xen_orchestra.py b/tests/unit/plugins/inventory/test_xen_orchestra.py index bae038e807..d626fb988b 100644 --- a/tests/unit/plugins/inventory/test_xen_orchestra.py +++ b/tests/unit/plugins/inventory/test_xen_orchestra.py @@ -146,7 +146,7 @@ def serialize_groups(groups): return list(map(str, groups)) -@ pytest.fixture(scope="module") +@pytest.fixture(scope="module") def inventory(): r = InventoryModule() r.inventory = InventoryData() diff --git a/tests/unit/plugins/lookup/test_bitwarden.py b/tests/unit/plugins/lookup/test_bitwarden.py index 9270dd44e1..04cad8d6c8 100644 --- a/tests/unit/plugins/lookup/test_bitwarden.py +++ b/tests/unit/plugins/lookup/test_bitwarden.py @@ -6,6 +6,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type +import re from ansible_collections.community.general.tests.unit.compat import unittest from ansible_collections.community.general.tests.unit.compat.mock import patch @@ -13,8 +14,10 @@ from ansible.errors import AnsibleError from ansible.module_utils import six from ansible.plugins.loader import lookup_loader from ansible_collections.community.general.plugins.lookup.bitwarden import Bitwarden +from ansible.parsing.ajson import AnsibleJSONEncoder MOCK_COLLECTION_ID = "3b12a9da-7c49-40b8-ad33-aede017a7ead" +MOCK_ORGANIZATION_ID = "292ba0c6-f289-11ee-9301-ef7b639ccd2a" MOCK_RECORDS = [ { @@ -48,7 +51,7 @@ MOCK_RECORDS = [ "name": "a_test", "notes": None, "object": "item", - "organizationId": None, + "organizationId": MOCK_ORGANIZATION_ID, "passwordHistory": [ { "lastUsedDate": "2022-07-26T23:03:23.405Z", @@ -68,9 +71,7 @@ MOCK_RECORDS = [ "type": 1 }, { - "collectionIds": [ - MOCK_COLLECTION_ID - ], + "collectionIds": [], "deletedDate": None, "favorite": False, "folderId": None, @@ -106,10 +107,30 @@ MOCK_RECORDS = [ "name": "dupe_name", "notes": None, "object": "item", - "organizationId": None, + "organizationId": MOCK_ORGANIZATION_ID, "reprompt": 0, "revisionDate": "2022-07-27T03:42:46.673Z", "type": 1 + }, + { + "collectionIds": [], + "deletedDate": None, + "favorite": False, + "folderId": None, + "id": "2bf517be-fb13-11ee-be89-a345aa369a94", + "login": { + "password": "e", + "passwordRevisionDate": None, + "totp": None, + "username": "f" + }, + "name": "non_collection_org_record", + "notes": None, + "object": "item", + "organizationId": MOCK_ORGANIZATION_ID, + "reprompt": 0, + "revisionDate": "2024-14-15T11:30:00.000Z", + "type": 1 } ] @@ -118,11 +139,41 @@ class MockBitwarden(Bitwarden): unlocked = True - def _get_matches(self, search_value=None, search_field="name", collection_id=None): - if not search_value and collection_id: - return list(filter(lambda record: collection_id in record['collectionIds'], MOCK_RECORDS)) + def _run(self, args, stdin=None, expected_rc=0): + if args[0] == 'get': + if args[1] == 'item': + for item in MOCK_RECORDS: + if item.get('id') == args[2]: + return AnsibleJSONEncoder().encode(item), '' + if args[0] == 'list': + if args[1] == 'items': + try: + search_value = args[args.index('--search') + 1] + except ValueError: + search_value = None - return list(filter(lambda record: record[search_field] == search_value, MOCK_RECORDS)) + try: + collection_to_filter = args[args.index('--collectionid') + 1] + except ValueError: + collection_to_filter = None + + try: + organization_to_filter = args[args.index('--organizationid') + 1] + except ValueError: + organization_to_filter = None + + items = [] + for item in MOCK_RECORDS: + if search_value and not re.search(search_value, item.get('name')): + continue + if collection_to_filter and collection_to_filter not in item.get('collectionIds', []): + continue + if organization_to_filter and item.get('organizationId') != organization_to_filter: + continue + items.append(item) + return AnsibleJSONEncoder().encode(items), '' + + return '[]', '' class LoggedOutMockBitwarden(MockBitwarden): @@ -194,4 +245,19 @@ class TestLookupModule(unittest.TestCase): @patch('ansible_collections.community.general.plugins.lookup.bitwarden._bitwarden', new=MockBitwarden()) def test_bitwarden_plugin_full_collection(self): # Try to retrieve the full records of the given collection. - self.assertEqual(MOCK_RECORDS, self.lookup.run(None, collection_id=MOCK_COLLECTION_ID)[0]) + self.assertEqual([MOCK_RECORDS[0], MOCK_RECORDS[2]], self.lookup.run(None, collection_id=MOCK_COLLECTION_ID)[0]) + + @patch('ansible_collections.community.general.plugins.lookup.bitwarden._bitwarden', new=MockBitwarden()) + def test_bitwarden_plugin_full_organization(self): + self.assertEqual([MOCK_RECORDS[0], MOCK_RECORDS[2], MOCK_RECORDS[3]], + self.lookup.run(None, organization_id=MOCK_ORGANIZATION_ID)[0]) + + @patch('ansible_collections.community.general.plugins.lookup.bitwarden._bitwarden', new=MockBitwarden()) + def test_bitwarden_plugin_filter_organization(self): + self.assertEqual([MOCK_RECORDS[2]], + self.lookup.run(['dupe_name'], organization_id=MOCK_ORGANIZATION_ID)[0]) + + @patch('ansible_collections.community.general.plugins.lookup.bitwarden._bitwarden', new=MockBitwarden()) + def test_bitwarden_plugin_full_collection_organization(self): + self.assertEqual([MOCK_RECORDS[0], MOCK_RECORDS[2]], self.lookup.run(None, + collection_id=MOCK_COLLECTION_ID, organization_id=MOCK_ORGANIZATION_ID)[0]) diff --git a/tests/unit/plugins/lookup/test_merge_variables.py b/tests/unit/plugins/lookup/test_merge_variables.py index 66cb2f08bb..ba8209439a 100644 --- a/tests/unit/plugins/lookup/test_merge_variables.py +++ b/tests/unit/plugins/lookup/test_merge_variables.py @@ -18,6 +18,17 @@ from ansible_collections.community.general.plugins.lookup import merge_variables class TestMergeVariablesLookup(unittest.TestCase): + class HostVarsMock(dict): + + def __getattr__(self, item): + return super().__getitem__(item) + + def __setattr__(self, item, value): + return super().__setitem__(item, value) + + def raw_get(self, host): + return super().__getitem__(host) + def setUp(self): self.loader = DictDataLoader({}) self.templar = Templar(loader=self.loader, variables={}) @@ -141,25 +152,28 @@ class TestMergeVariablesLookup(unittest.TestCase): {'var': [{'item5': 'value5', 'item6': 'value6'}]}, ]) def test_merge_dict_group_all(self, mock_set_options, mock_get_option, mock_template): - results = self.merge_vars_lookup.run(['__merge_var'], { - 'inventory_hostname': 'host1', - 'hostvars': { - 'host1': { - 'group_names': ['dummy1'], - 'inventory_hostname': 'host1', - '1testlist__merge_var': { - 'var': [{'item1': 'value1', 'item2': 'value2'}] - } - }, - 'host2': { - 'group_names': ['dummy1'], - 'inventory_hostname': 'host2', - '2otherlist__merge_var': { - 'var': [{'item5': 'value5', 'item6': 'value6'}] - } + hostvars = self.HostVarsMock({ + 'host1': { + 'group_names': ['dummy1'], + 'inventory_hostname': 'host1', + '1testlist__merge_var': { + 'var': [{'item1': 'value1', 'item2': 'value2'}] + } + }, + 'host2': { + 'group_names': ['dummy1'], + 'inventory_hostname': 'host2', + '2otherlist__merge_var': { + 'var': [{'item5': 'value5', 'item6': 'value6'}] } } }) + variables = { + 'inventory_hostname': 'host1', + 'hostvars': hostvars + } + + results = self.merge_vars_lookup.run(['__merge_var'], variables) self.assertEqual(results, [ {'var': [ @@ -175,32 +189,35 @@ class TestMergeVariablesLookup(unittest.TestCase): {'var': [{'item5': 'value5', 'item6': 'value6'}]}, ]) def test_merge_dict_group_single(self, mock_set_options, mock_get_option, mock_template): - results = self.merge_vars_lookup.run(['__merge_var'], { - 'inventory_hostname': 'host1', - 'hostvars': { - 'host1': { - 'group_names': ['dummy1'], - 'inventory_hostname': 'host1', - '1testlist__merge_var': { - 'var': [{'item1': 'value1', 'item2': 'value2'}] - } - }, - 'host2': { - 'group_names': ['dummy1'], - 'inventory_hostname': 'host2', - '2otherlist__merge_var': { - 'var': [{'item5': 'value5', 'item6': 'value6'}] - } - }, - 'host3': { - 'group_names': ['dummy2'], - 'inventory_hostname': 'host3', - '3otherlist__merge_var': { - 'var': [{'item3': 'value3', 'item4': 'value4'}] - } + hostvars = self.HostVarsMock({ + 'host1': { + 'group_names': ['dummy1'], + 'inventory_hostname': 'host1', + '1testlist__merge_var': { + 'var': [{'item1': 'value1', 'item2': 'value2'}] + } + }, + 'host2': { + 'group_names': ['dummy1'], + 'inventory_hostname': 'host2', + '2otherlist__merge_var': { + 'var': [{'item5': 'value5', 'item6': 'value6'}] + } + }, + 'host3': { + 'group_names': ['dummy2'], + 'inventory_hostname': 'host3', + '3otherlist__merge_var': { + 'var': [{'item3': 'value3', 'item4': 'value4'}] } } }) + variables = { + 'inventory_hostname': 'host1', + 'hostvars': hostvars + } + + results = self.merge_vars_lookup.run(['__merge_var'], variables) self.assertEqual(results, [ {'var': [ @@ -216,32 +233,34 @@ class TestMergeVariablesLookup(unittest.TestCase): {'var': [{'item5': 'value5', 'item6': 'value6'}]}, ]) def test_merge_dict_group_multiple(self, mock_set_options, mock_get_option, mock_template): - results = self.merge_vars_lookup.run(['__merge_var'], { - 'inventory_hostname': 'host1', - 'hostvars': { - 'host1': { - 'group_names': ['dummy1'], - 'inventory_hostname': 'host1', - '1testlist__merge_var': { - 'var': [{'item1': 'value1', 'item2': 'value2'}] - } - }, - 'host2': { - 'group_names': ['dummy2'], - 'inventory_hostname': 'host2', - '2otherlist__merge_var': { - 'var': [{'item5': 'value5', 'item6': 'value6'}] - } - }, - 'host3': { - 'group_names': ['dummy3'], - 'inventory_hostname': 'host3', - '3otherlist__merge_var': { - 'var': [{'item3': 'value3', 'item4': 'value4'}] - } + hostvars = self.HostVarsMock({ + 'host1': { + 'group_names': ['dummy1'], + 'inventory_hostname': 'host1', + '1testlist__merge_var': { + 'var': [{'item1': 'value1', 'item2': 'value2'}] + } + }, + 'host2': { + 'group_names': ['dummy2'], + 'inventory_hostname': 'host2', + '2otherlist__merge_var': { + 'var': [{'item5': 'value5', 'item6': 'value6'}] + } + }, + 'host3': { + 'group_names': ['dummy3'], + 'inventory_hostname': 'host3', + '3otherlist__merge_var': { + 'var': [{'item3': 'value3', 'item4': 'value4'}] } } }) + variables = { + 'inventory_hostname': 'host1', + 'hostvars': hostvars + } + results = self.merge_vars_lookup.run(['__merge_var'], variables) self.assertEqual(results, [ {'var': [ @@ -257,26 +276,27 @@ class TestMergeVariablesLookup(unittest.TestCase): ['item5'], ]) def test_merge_list_group_multiple(self, mock_set_options, mock_get_option, mock_template): - print() - results = self.merge_vars_lookup.run(['__merge_var'], { - 'inventory_hostname': 'host1', - 'hostvars': { - 'host1': { - 'group_names': ['dummy1'], - 'inventory_hostname': 'host1', - '1testlist__merge_var': ['item1'] - }, - 'host2': { - 'group_names': ['dummy2'], - 'inventory_hostname': 'host2', - '2otherlist__merge_var': ['item5'] - }, - 'host3': { - 'group_names': ['dummy3'], - 'inventory_hostname': 'host3', - '3otherlist__merge_var': ['item3'] - } + hostvars = self.HostVarsMock({ + 'host1': { + 'group_names': ['dummy1'], + 'inventory_hostname': 'host1', + '1testlist__merge_var': ['item1'] + }, + 'host2': { + 'group_names': ['dummy2'], + 'inventory_hostname': 'host2', + '2otherlist__merge_var': ['item5'] + }, + 'host3': { + 'group_names': ['dummy3'], + 'inventory_hostname': 'host3', + '3otherlist__merge_var': ['item3'] } }) + variables = { + 'inventory_hostname': 'host1', + 'hostvars': hostvars + } + results = self.merge_vars_lookup.run(['__merge_var'], variables) self.assertEqual(results, [['item1', 'item5']]) diff --git a/tests/unit/plugins/module_utils/test_cmd_runner.py b/tests/unit/plugins/module_utils/test_cmd_runner.py index 86576e8ce4..da93292197 100644 --- a/tests/unit/plugins/module_utils/test_cmd_runner.py +++ b/tests/unit/plugins/module_utils/test_cmd_runner.py @@ -7,6 +7,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type from sys import version_info +from functools import partial import pytest @@ -15,55 +16,68 @@ from ansible_collections.community.general.plugins.module_utils.cmd_runner impor TC_FORMATS = dict( - simple_boolean__true=(cmd_runner_fmt.as_bool, ("--superflag",), True, ["--superflag"]), - simple_boolean__false=(cmd_runner_fmt.as_bool, ("--superflag",), False, []), - simple_boolean__none=(cmd_runner_fmt.as_bool, ("--superflag",), None, []), - simple_boolean_both__true=(cmd_runner_fmt.as_bool, ("--superflag", "--falseflag"), True, ["--superflag"]), - simple_boolean_both__false=(cmd_runner_fmt.as_bool, ("--superflag", "--falseflag"), False, ["--falseflag"]), - simple_boolean_both__none=(cmd_runner_fmt.as_bool, ("--superflag", "--falseflag"), None, ["--falseflag"]), - simple_boolean_both__none_ig=(cmd_runner_fmt.as_bool, ("--superflag", "--falseflag", True), None, []), - simple_boolean_not__true=(cmd_runner_fmt.as_bool_not, ("--superflag",), True, []), - simple_boolean_not__false=(cmd_runner_fmt.as_bool_not, ("--superflag",), False, ["--superflag"]), - simple_boolean_not__none=(cmd_runner_fmt.as_bool_not, ("--superflag",), None, ["--superflag"]), - simple_optval__str=(cmd_runner_fmt.as_optval, ("-t",), "potatoes", ["-tpotatoes"]), - simple_optval__int=(cmd_runner_fmt.as_optval, ("-t",), 42, ["-t42"]), - simple_opt_val__str=(cmd_runner_fmt.as_opt_val, ("-t",), "potatoes", ["-t", "potatoes"]), - simple_opt_val__int=(cmd_runner_fmt.as_opt_val, ("-t",), 42, ["-t", "42"]), - simple_opt_eq_val__str=(cmd_runner_fmt.as_opt_eq_val, ("--food",), "potatoes", ["--food=potatoes"]), - simple_opt_eq_val__int=(cmd_runner_fmt.as_opt_eq_val, ("--answer",), 42, ["--answer=42"]), - simple_list_potato=(cmd_runner_fmt.as_list, (), "literal_potato", ["literal_potato"]), - simple_list_42=(cmd_runner_fmt.as_list, (), 42, ["42"]), - simple_map=(cmd_runner_fmt.as_map, ({'a': 1, 'b': 2, 'c': 3},), 'b', ["2"]), - simple_default_type__list=(cmd_runner_fmt.as_default_type, ("list",), [1, 2, 3, 5, 8], ["--1", "--2", "--3", "--5", "--8"]), - simple_default_type__bool_true=(cmd_runner_fmt.as_default_type, ("bool", "what"), True, ["--what"]), - simple_default_type__bool_false=(cmd_runner_fmt.as_default_type, ("bool", "what"), False, []), - simple_default_type__potato=(cmd_runner_fmt.as_default_type, ("any-other-type", "potato"), "42", ["--potato", "42"]), - simple_fixed_true=(cmd_runner_fmt.as_fixed, [("--always-here", "--forever")], True, ["--always-here", "--forever"]), - simple_fixed_false=(cmd_runner_fmt.as_fixed, [("--always-here", "--forever")], False, ["--always-here", "--forever"]), - simple_fixed_none=(cmd_runner_fmt.as_fixed, [("--always-here", "--forever")], None, ["--always-here", "--forever"]), - simple_fixed_str=(cmd_runner_fmt.as_fixed, [("--always-here", "--forever")], "something", ["--always-here", "--forever"]), + simple_boolean__true=(partial(cmd_runner_fmt.as_bool, "--superflag"), True, ["--superflag"], None), + simple_boolean__false=(partial(cmd_runner_fmt.as_bool, "--superflag"), False, [], None), + simple_boolean__none=(partial(cmd_runner_fmt.as_bool, "--superflag"), None, [], None), + simple_boolean_both__true=(partial(cmd_runner_fmt.as_bool, "--superflag", "--falseflag"), True, ["--superflag"], None), + simple_boolean_both__false=(partial(cmd_runner_fmt.as_bool, "--superflag", "--falseflag"), False, ["--falseflag"], None), + simple_boolean_both__none=(partial(cmd_runner_fmt.as_bool, "--superflag", "--falseflag"), None, ["--falseflag"], None), + simple_boolean_both__none_ig=(partial(cmd_runner_fmt.as_bool, "--superflag", "--falseflag", True), None, [], None), + simple_boolean_not__true=(partial(cmd_runner_fmt.as_bool_not, "--superflag"), True, [], None), + simple_boolean_not__false=(partial(cmd_runner_fmt.as_bool_not, "--superflag"), False, ["--superflag"], None), + simple_boolean_not__none=(partial(cmd_runner_fmt.as_bool_not, "--superflag"), None, ["--superflag"], None), + simple_optval__str=(partial(cmd_runner_fmt.as_optval, "-t"), "potatoes", ["-tpotatoes"], None), + simple_optval__int=(partial(cmd_runner_fmt.as_optval, "-t"), 42, ["-t42"], None), + simple_opt_val__str=(partial(cmd_runner_fmt.as_opt_val, "-t"), "potatoes", ["-t", "potatoes"], None), + simple_opt_val__int=(partial(cmd_runner_fmt.as_opt_val, "-t"), 42, ["-t", "42"], None), + simple_opt_eq_val__str=(partial(cmd_runner_fmt.as_opt_eq_val, "--food"), "potatoes", ["--food=potatoes"], None), + simple_opt_eq_val__int=(partial(cmd_runner_fmt.as_opt_eq_val, "--answer"), 42, ["--answer=42"], None), + simple_list_empty=(cmd_runner_fmt.as_list, [], [], None), + simple_list_potato=(cmd_runner_fmt.as_list, "literal_potato", ["literal_potato"], None), + simple_list_42=(cmd_runner_fmt.as_list, 42, ["42"], None), + simple_list_min_len_ok=(partial(cmd_runner_fmt.as_list, min_len=1), 42, ["42"], None), + simple_list_min_len_fail=(partial(cmd_runner_fmt.as_list, min_len=10), 42, None, ValueError), + simple_list_max_len_ok=(partial(cmd_runner_fmt.as_list, max_len=1), 42, ["42"], None), + simple_list_max_len_fail=(partial(cmd_runner_fmt.as_list, max_len=2), [42, 42, 42], None, ValueError), + simple_map=(partial(cmd_runner_fmt.as_map, {'a': 1, 'b': 2, 'c': 3}), 'b', ["2"], None), + simple_default_type__list=(partial(cmd_runner_fmt.as_default_type, "list"), [1, 2, 3, 5, 8], ["--1", "--2", "--3", "--5", "--8"], None), + simple_default_type__bool_true=(partial(cmd_runner_fmt.as_default_type, "bool", "what"), True, ["--what"], None), + simple_default_type__bool_false=(partial(cmd_runner_fmt.as_default_type, "bool", "what"), False, [], None), + simple_default_type__potato=(partial(cmd_runner_fmt.as_default_type, "any-other-type", "potato"), "42", ["--potato", "42"], None), + simple_fixed_true=(partial(cmd_runner_fmt.as_fixed, ["--always-here", "--forever"]), True, ["--always-here", "--forever"], None), + simple_fixed_false=(partial(cmd_runner_fmt.as_fixed, ["--always-here", "--forever"]), False, ["--always-here", "--forever"], None), + simple_fixed_none=(partial(cmd_runner_fmt.as_fixed, ["--always-here", "--forever"]), None, ["--always-here", "--forever"], None), + simple_fixed_str=(partial(cmd_runner_fmt.as_fixed, ["--always-here", "--forever"]), "something", ["--always-here", "--forever"], None), + stack_optval__str=(partial(cmd_runner_fmt.stack(cmd_runner_fmt.as_optval), "-t"), ["potatoes", "bananas"], ["-tpotatoes", "-tbananas"], None), + stack_opt_val__str=(partial(cmd_runner_fmt.stack(cmd_runner_fmt.as_opt_val), "-t"), ["potatoes", "bananas"], ["-t", "potatoes", "-t", "bananas"], None), + stack_opt_eq_val__int=(partial(cmd_runner_fmt.stack(cmd_runner_fmt.as_opt_eq_val), "--answer"), [42, 17], ["--answer=42", "--answer=17"], None), ) if tuple(version_info) >= (3, 1): from collections import OrderedDict # needs OrderedDict to provide a consistent key order TC_FORMATS["simple_default_type__dict"] = ( # type: ignore - cmd_runner_fmt.as_default_type, - ("dict",), + partial(cmd_runner_fmt.as_default_type, "dict"), OrderedDict((('a', 1), ('b', 2))), - ["--a=1", "--b=2"] + ["--a=1", "--b=2"], + None ) TC_FORMATS_IDS = sorted(TC_FORMATS.keys()) -@pytest.mark.parametrize('func, fmt_opt, value, expected', +@pytest.mark.parametrize('func, value, expected, exception', (TC_FORMATS[tc] for tc in TC_FORMATS_IDS), ids=TC_FORMATS_IDS) -def test_arg_format(func, fmt_opt, value, expected): - fmt_func = func(*fmt_opt) - actual = fmt_func(value, ctx_ignore_none=True) - print("formatted string = {0}".format(actual)) - assert actual == expected, "actual = {0}".format(actual) +def test_arg_format(func, value, expected, exception): + fmt_func = func() + try: + actual = fmt_func(value) + print("formatted string = {0}".format(actual)) + assert actual == expected, "actual = {0}".format(actual) + except Exception as e: + if exception is None: + raise + assert isinstance(e, exception) TC_RUNNER = dict( @@ -345,7 +359,7 @@ def test_runner_context(runner_input, cmd_execution, expected): ) def _assert_run_info(actual, expected): - reduced = dict((k, actual[k]) for k in expected.keys()) + reduced = {k: actual[k] for k in expected.keys()} assert reduced == expected, "{0}".format(reduced) def _assert_run(runner_input, cmd_execution, expected, ctx, results): diff --git a/tests/unit/plugins/module_utils/test_module_helper.py b/tests/unit/plugins/module_utils/test_module_helper.py index b2cd58690d..b1e2eafc7f 100644 --- a/tests/unit/plugins/module_utils/test_module_helper.py +++ b/tests/unit/plugins/module_utils/test_module_helper.py @@ -119,28 +119,27 @@ def test_variable_meta_change(): assert vd.has_changed('d') -class MockMH(object): - changed = None - - def _div(self, x, y): - return x / y - - func_none = cause_changes()(_div) - func_onsucc = cause_changes(on_success=True)(_div) - func_onfail = cause_changes(on_failure=True)(_div) - func_onboth = cause_changes(on_success=True, on_failure=True)(_div) - - -CAUSE_CHG_DECO_PARAMS = ['method', 'expect_exception', 'expect_changed'] +# +# DEPRECATION NOTICE +# Parameters on_success and on_failure are deprecated and will be removed in community.genral 12.0.0 +# Remove testcases with those params when releasing 12.0.0 +# +CAUSE_CHG_DECO_PARAMS = ['deco_args', 'expect_exception', 'expect_changed'] CAUSE_CHG_DECO = dict( - none_succ=dict(method='func_none', expect_exception=False, expect_changed=None), - none_fail=dict(method='func_none', expect_exception=True, expect_changed=None), - onsucc_succ=dict(method='func_onsucc', expect_exception=False, expect_changed=True), - onsucc_fail=dict(method='func_onsucc', expect_exception=True, expect_changed=None), - onfail_succ=dict(method='func_onfail', expect_exception=False, expect_changed=None), - onfail_fail=dict(method='func_onfail', expect_exception=True, expect_changed=True), - onboth_succ=dict(method='func_onboth', expect_exception=False, expect_changed=True), - onboth_fail=dict(method='func_onboth', expect_exception=True, expect_changed=True), + none_succ=dict(deco_args={}, expect_exception=False, expect_changed=None), + none_fail=dict(deco_args={}, expect_exception=True, expect_changed=None), + onsucc_succ=dict(deco_args=dict(on_success=True), expect_exception=False, expect_changed=True), + onsucc_fail=dict(deco_args=dict(on_success=True), expect_exception=True, expect_changed=None), + onfail_succ=dict(deco_args=dict(on_failure=True), expect_exception=False, expect_changed=None), + onfail_fail=dict(deco_args=dict(on_failure=True), expect_exception=True, expect_changed=True), + onboth_succ=dict(deco_args=dict(on_success=True, on_failure=True), expect_exception=False, expect_changed=True), + onboth_fail=dict(deco_args=dict(on_success=True, on_failure=True), expect_exception=True, expect_changed=True), + whensucc_succ=dict(deco_args=dict(when="success"), expect_exception=False, expect_changed=True), + whensucc_fail=dict(deco_args=dict(when="success"), expect_exception=True, expect_changed=None), + whenfail_succ=dict(deco_args=dict(when="failure"), expect_exception=False, expect_changed=None), + whenfail_fail=dict(deco_args=dict(when="failure"), expect_exception=True, expect_changed=True), + whenalways_succ=dict(deco_args=dict(when="always"), expect_exception=False, expect_changed=True), + whenalways_fail=dict(deco_args=dict(when="always"), expect_exception=True, expect_changed=True), ) CAUSE_CHG_DECO_IDS = sorted(CAUSE_CHG_DECO.keys()) @@ -150,12 +149,20 @@ CAUSE_CHG_DECO_IDS = sorted(CAUSE_CHG_DECO.keys()) for param in CAUSE_CHG_DECO_PARAMS] for tc in CAUSE_CHG_DECO_IDS], ids=CAUSE_CHG_DECO_IDS) -def test_cause_changes_deco(method, expect_exception, expect_changed): +def test_cause_changes_deco(deco_args, expect_exception, expect_changed): + + class MockMH(object): + changed = None + + @cause_changes(**deco_args) + def div_(self, x, y): + return x / y + mh = MockMH() if expect_exception: with pytest.raises(Exception): - getattr(mh, method)(1, 0) + mh.div_(1, 0) else: - getattr(mh, method)(9, 3) + mh.div_(9, 3) assert mh.changed == expect_changed diff --git a/tests/unit/plugins/module_utils/test_python_runner.py b/tests/unit/plugins/module_utils/test_python_runner.py new file mode 100644 index 0000000000..8572ee7d78 --- /dev/null +++ b/tests/unit/plugins/module_utils/test_python_runner.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Alexei Znamensky +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os + +import pytest + +from ansible_collections.community.general.tests.unit.compat.mock import MagicMock, PropertyMock +from ansible_collections.community.general.plugins.module_utils.cmd_runner import cmd_runner_fmt +from ansible_collections.community.general.plugins.module_utils.python_runner import PythonRunner + + +TC_RUNNER = dict( + # SAMPLE: This shows all possible elements of a test case. It does not actually run. + # + # testcase_name=( + # # input + # dict( + # args_bundle = dict( + # param1=dict( + # type="int", + # value=11, + # fmt_func=cmd_runner_fmt.as_opt_eq_val, + # fmt_arg="--answer", + # ), + # param2=dict( + # fmt_func=cmd_runner_fmt.as_bool, + # fmt_arg="--bb-here", + # ) + # ), + # runner_init_args = dict( + # command="testing", + # default_args_order=(), + # check_rc=False, + # force_lang="C", + # path_prefix=None, + # environ_update=None, + # ), + # runner_ctx_args = dict( + # args_order=['aa', 'bb'], + # output_process=None, + # ignore_value_none=True, + # ), + # ), + # # command execution + # dict( + # runner_ctx_run_args = dict(bb=True), + # rc = 0, + # out = "", + # err = "", + # ), + # # expected + # dict( + # results=(), + # run_info=dict( + # cmd=['/mock/bin/testing', '--answer=11', '--bb-here'], + # environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + # ), + # exc=None, + # ), + # ), + # + aa_bb=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=cmd_runner_fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=cmd_runner_fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(command="testing"), + runner_ctx_args=dict(args_order=['aa', 'bb']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/mock/bin/python', 'testing', '--answer=11', '--bb-here'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + args_order=('aa', 'bb'), + ), + ), + ), + aa_bb_py3=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=cmd_runner_fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=cmd_runner_fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(command="toasting", python="python3"), + runner_ctx_args=dict(args_order=['aa', 'bb']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/mock/bin/python3', 'toasting', '--answer=11', '--bb-here'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + args_order=('aa', 'bb'), + ), + ), + ), + aa_bb_abspath=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=cmd_runner_fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=cmd_runner_fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(command="toasting", python="/crazy/local/bin/python3"), + runner_ctx_args=dict(args_order=['aa', 'bb']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/crazy/local/bin/python3', 'toasting', '--answer=11', '--bb-here'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C'}, + args_order=('aa', 'bb'), + ), + ), + ), + aa_bb_venv=( + dict( + args_bundle=dict( + aa=dict(type="int", value=11, fmt_func=cmd_runner_fmt.as_opt_eq_val, fmt_arg="--answer"), + bb=dict(fmt_func=cmd_runner_fmt.as_bool, fmt_arg="--bb-here"), + ), + runner_init_args=dict(command="toasting", venv="/venv"), + runner_ctx_args=dict(args_order=['aa', 'bb']), + ), + dict(runner_ctx_run_args=dict(bb=True), rc=0, out="", err=""), + dict( + run_info=dict( + cmd=['/venv/bin/python', 'toasting', '--answer=11', '--bb-here'], + environ_update={'LANGUAGE': 'C', 'LC_ALL': 'C', 'VIRTUAL_ENV': '/venv', 'PATH': '/venv/bin'}, + args_order=('aa', 'bb'), + ), + ), + ), +) +TC_RUNNER_IDS = sorted(TC_RUNNER.keys()) + + +@pytest.mark.parametrize('runner_input, cmd_execution, expected', + (TC_RUNNER[tc] for tc in TC_RUNNER_IDS), + ids=TC_RUNNER_IDS) +def test_runner_context(runner_input, cmd_execution, expected): + arg_spec = {} + params = {} + arg_formats = {} + for k, v in runner_input['args_bundle'].items(): + try: + arg_spec[k] = {'type': v['type']} + except KeyError: + pass + try: + params[k] = v['value'] + except KeyError: + pass + try: + arg_formats[k] = v['fmt_func'](v['fmt_arg']) + except KeyError: + pass + + orig_results = tuple(cmd_execution[x] for x in ('rc', 'out', 'err')) + + print("arg_spec={0}\nparams={1}\narg_formats={2}\n".format( + arg_spec, + params, + arg_formats, + )) + + module = MagicMock() + type(module).argument_spec = PropertyMock(return_value=arg_spec) + type(module).params = PropertyMock(return_value=params) + module.get_bin_path.return_value = os.path.join( + runner_input["runner_init_args"].get("venv", "/mock"), + "bin", + runner_input["runner_init_args"].get("python", "python") + ) + module.run_command.return_value = orig_results + + runner = PythonRunner( + module=module, + arg_formats=arg_formats, + **runner_input['runner_init_args'] + ) + + def _extract_path(run_info): + path = run_info.get("environ_update", {}).get("PATH") + if path is not None: + run_info["environ_update"] = { + k: v + for k, v in run_info["environ_update"].items() + if k != "PATH" + } + return run_info, path + + def _assert_run_info_env_path(actual, expected): + actual2 = set(actual.split(":")) + assert expected in actual2, "Missing expected path {0} in output PATH: {1}".format(expected, actual) + + def _assert_run_info(actual, expected): + reduced = {k: actual[k] for k in expected.keys()} + reduced, act_path = _extract_path(reduced) + expected, exp_path = _extract_path(expected) + if exp_path is not None: + _assert_run_info_env_path(act_path, exp_path) + assert reduced == expected, "{0}".format(reduced) + + def _assert_run(expected, ctx, results): + _assert_run_info(ctx.run_info, expected['run_info']) + assert results == expected.get('results', orig_results) + + exc = expected.get("exc") + if exc: + with pytest.raises(exc): + with runner.context(**runner_input['runner_ctx_args']) as ctx: + results = ctx.run(**cmd_execution['runner_ctx_run_args']) + _assert_run(expected, ctx, results) + + else: + with runner.context(**runner_input['runner_ctx_args']) as ctx: + results = ctx.run(**cmd_execution['runner_ctx_run_args']) + _assert_run(expected, ctx, results) diff --git a/tests/unit/plugins/modules/helper.py b/tests/unit/plugins/modules/helper.py index a7322bf4d8..e012980afe 100644 --- a/tests/unit/plugins/modules/helper.py +++ b/tests/unit/plugins/modules/helper.py @@ -9,7 +9,6 @@ __metaclass__ = type import sys import json from collections import namedtuple -from itertools import chain, repeat import pytest import yaml @@ -52,9 +51,9 @@ class _BaseContext(object): test_flags = self.test_flags() if test_flags.get("skip"): - pytest.skip() + pytest.skip(test_flags.get("skip")) if test_flags.get("xfail"): - pytest.xfail() + pytest.xfail(test_flags.get("xfail")) func() @@ -76,12 +75,21 @@ class _RunCmdContext(_BaseContext): self.mock_run_cmd = self._make_mock_run_cmd() def _make_mock_run_cmd(self): - call_results = [(x.rc, x.out, x.err) for x in self.run_cmd_calls] - error_call_results = (123, - "OUT: testcase has not enough run_command calls", - "ERR: testcase has not enough run_command calls") + def _results(): + for result in [(x.rc, x.out, x.err) for x in self.run_cmd_calls]: + yield result + raise Exception("testcase has not enough run_command calls") + + results = _results() + + def side_effect(self_, **kwargs): + result = next(results) + if kwargs.get("check_rc", False) and result[0] != 0: + raise Exception("rc = {0}".format(result[0])) + return result + mock_run_command = self.mocker.patch('ansible.module_utils.basic.AnsibleModule.run_command', - side_effect=chain(call_results, repeat(error_call_results))) + side_effect=side_effect) return mock_run_command def check_results(self, results): diff --git a/tests/unit/plugins/modules/test_bootc_manage.py b/tests/unit/plugins/modules/test_bootc_manage.py new file mode 100644 index 0000000000..5393a57a07 --- /dev/null +++ b/tests/unit/plugins/modules/test_bootc_manage.py @@ -0,0 +1,72 @@ +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.plugins.modules import bootc_manage +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + + +class TestBootcManageModule(ModuleTestCase): + + def setUp(self): + super(TestBootcManageModule, self).setUp() + self.module = bootc_manage + + def tearDown(self): + super(TestBootcManageModule, self).tearDown() + + def test_switch_without_image(self): + """Failure if state is 'switch' but no image provided""" + set_module_args({'state': 'switch'}) + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], "state is switch but all of the following are missing: image") + + def test_switch_with_image(self): + """Test successful switch with image provided""" + set_module_args({'state': 'switch', 'image': 'example.com/image:latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (0, 'Queued for next boot: ', '') + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertTrue(result.exception.args[0]['changed']) + + def test_latest_state(self): + """Test successful upgrade to the latest state""" + set_module_args({'state': 'latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (0, 'Queued for next boot: ', '') + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertTrue(result.exception.args[0]['changed']) + + def test_latest_state_no_change(self): + """Test no change for latest state""" + set_module_args({'state': 'latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (0, 'No changes in ', '') + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + self.assertFalse(result.exception.args[0]['changed']) + + def test_switch_image_failure(self): + """Test failure during image switch""" + set_module_args({'state': 'switch', 'image': 'example.com/image:latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (1, '', 'ERROR') + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'ERROR: Command execution failed.') + + def test_latest_state_failure(self): + """Test failure during upgrade""" + set_module_args({'state': 'latest'}) + with patch('ansible.module_utils.basic.AnsibleModule.run_command') as run_command_mock: + run_command_mock.return_value = (1, '', 'ERROR') + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + self.assertEqual(result.exception.args[0]['msg'], 'ERROR: Command execution failed.') diff --git a/tests/unit/plugins/modules/test_cpanm.yaml b/tests/unit/plugins/modules/test_cpanm.yaml index 3ed718d483..4eed957206 100644 --- a/tests/unit/plugins/modules/test_cpanm.yaml +++ b/tests/unit/plugins/modules/test_cpanm.yaml @@ -7,6 +7,7 @@ - id: install_dancer_compatibility input: name: Dancer + mode: compatibility output: changed: true run_command_calls: @@ -23,6 +24,7 @@ - id: install_dancer_already_installed_compatibility input: name: Dancer + mode: compatibility output: changed: false run_command_calls: @@ -34,7 +36,6 @@ - id: install_dancer input: name: Dancer - mode: new output: changed: true run_command_calls: @@ -46,6 +47,7 @@ - id: install_distribution_file_compatibility input: name: MIYAGAWA/Plack-0.99_05.tar.gz + mode: compatibility output: changed: true run_command_calls: @@ -57,7 +59,6 @@ - id: install_distribution_file input: name: MIYAGAWA/Plack-0.99_05.tar.gz - mode: new output: changed: true run_command_calls: diff --git a/tests/unit/plugins/modules/test_datadog_downtime.py.disabled b/tests/unit/plugins/modules/test_datadog_downtime.py similarity index 86% rename from tests/unit/plugins/modules/test_datadog_downtime.py.disabled rename to tests/unit/plugins/modules/test_datadog_downtime.py index 52f27710cf..e1ecbfa66f 100644 --- a/tests/unit/plugins/modules/test_datadog_downtime.py.disabled +++ b/tests/unit/plugins/modules/test_datadog_downtime.py @@ -7,7 +7,7 @@ from __future__ import (absolute_import, division, print_function) __metaclass__ = type -from ansible_collections.community.general.plugins.modules.monitoring.datadog import datadog_downtime +from ansible_collections.community.general.plugins.modules import datadog_downtime from ansible_collections.community.general.tests.unit.compat.mock import MagicMock, patch from ansible_collections.community.general.tests.unit.plugins.modules.utils import ( AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args @@ -36,7 +36,7 @@ class TestDatadogDowntime(ModuleTestCase): set_module_args({}) self.module.main() - @patch("ansible_collections.community.general.plugins.modules.monitoring.datadog.datadog_downtime.DowntimesApi") + @patch("ansible_collections.community.general.plugins.modules.datadog_downtime.DowntimesApi") def test_create_downtime_when_no_id(self, downtimes_api_mock): set_module_args({ "monitor_tags": ["foo:bar"], @@ -60,10 +60,11 @@ class TestDatadogDowntime(ModuleTestCase): downtime.end = 2222 downtime.timezone = "UTC" downtime.recurrence = DowntimeRecurrence( - rrule="rrule" + rrule="rrule", + type="rrule" ) - create_downtime_mock = MagicMock(return_value=Downtime(id=12345)) + create_downtime_mock = MagicMock(return_value=self.__downtime_with_id(12345)) downtimes_api_mock.return_value = MagicMock(create_downtime=create_downtime_mock) with self.assertRaises(AnsibleExitJson) as result: self.module.main() @@ -71,7 +72,7 @@ class TestDatadogDowntime(ModuleTestCase): self.assertEqual(result.exception.args[0]['downtime']['id'], 12345) create_downtime_mock.assert_called_once_with(downtime) - @patch("ansible_collections.community.general.plugins.modules.monitoring.datadog.datadog_downtime.DowntimesApi") + @patch("ansible_collections.community.general.plugins.modules.datadog_downtime.DowntimesApi") def test_create_downtime_when_id_and_disabled(self, downtimes_api_mock): set_module_args({ "id": 1212, @@ -96,11 +97,16 @@ class TestDatadogDowntime(ModuleTestCase): downtime.end = 2222 downtime.timezone = "UTC" downtime.recurrence = DowntimeRecurrence( - rrule="rrule" + rrule="rrule", + type="rrule" ) - create_downtime_mock = MagicMock(return_value=Downtime(id=12345)) - get_downtime_mock = MagicMock(return_value=Downtime(id=1212, disabled=True)) + disabled_downtime = Downtime() + disabled_downtime.disabled = True + disabled_downtime.id = 1212 + + create_downtime_mock = MagicMock(return_value=self.__downtime_with_id(12345)) + get_downtime_mock = MagicMock(return_value=disabled_downtime) downtimes_api_mock.return_value = MagicMock( create_downtime=create_downtime_mock, get_downtime=get_downtime_mock ) @@ -111,7 +117,7 @@ class TestDatadogDowntime(ModuleTestCase): create_downtime_mock.assert_called_once_with(downtime) get_downtime_mock.assert_called_once_with(1212) - @patch("ansible_collections.community.general.plugins.modules.monitoring.datadog.datadog_downtime.DowntimesApi") + @patch("ansible_collections.community.general.plugins.modules.datadog_downtime.DowntimesApi") def test_update_downtime_when_not_disabled(self, downtimes_api_mock): set_module_args({ "id": 1212, @@ -136,11 +142,16 @@ class TestDatadogDowntime(ModuleTestCase): downtime.end = 2222 downtime.timezone = "UTC" downtime.recurrence = DowntimeRecurrence( - rrule="rrule" + rrule="rrule", + type="rrule" ) - update_downtime_mock = MagicMock(return_value=Downtime(id=1212)) - get_downtime_mock = MagicMock(return_value=Downtime(id=1212, disabled=False)) + enabled_downtime = Downtime() + enabled_downtime.disabled = False + enabled_downtime.id = 1212 + + update_downtime_mock = MagicMock(return_value=self.__downtime_with_id(1212)) + get_downtime_mock = MagicMock(return_value=enabled_downtime) downtimes_api_mock.return_value = MagicMock( update_downtime=update_downtime_mock, get_downtime=get_downtime_mock ) @@ -151,7 +162,7 @@ class TestDatadogDowntime(ModuleTestCase): update_downtime_mock.assert_called_once_with(1212, downtime) get_downtime_mock.assert_called_once_with(1212) - @patch("ansible_collections.community.general.plugins.modules.monitoring.datadog.datadog_downtime.DowntimesApi") + @patch("ansible_collections.community.general.plugins.modules.datadog_downtime.DowntimesApi") def test_update_downtime_no_change(self, downtimes_api_mock): set_module_args({ "id": 1212, @@ -176,7 +187,8 @@ class TestDatadogDowntime(ModuleTestCase): downtime.end = 2222 downtime.timezone = "UTC" downtime.recurrence = DowntimeRecurrence( - rrule="rrule" + rrule="rrule", + type="rrule" ) downtime_get = Downtime() @@ -205,7 +217,7 @@ class TestDatadogDowntime(ModuleTestCase): update_downtime_mock.assert_called_once_with(1212, downtime) get_downtime_mock.assert_called_once_with(1212) - @patch("ansible_collections.community.general.plugins.modules.monitoring.datadog.datadog_downtime.DowntimesApi") + @patch("ansible_collections.community.general.plugins.modules.datadog_downtime.DowntimesApi") def test_delete_downtime(self, downtimes_api_mock): set_module_args({ "id": 1212, @@ -215,12 +227,16 @@ class TestDatadogDowntime(ModuleTestCase): }) cancel_downtime_mock = MagicMock() - get_downtime_mock = MagicMock(return_value=Downtime(id=1212)) downtimes_api_mock.return_value = MagicMock( - get_downtime=get_downtime_mock, + get_downtime=self.__downtime_with_id, cancel_downtime=cancel_downtime_mock ) with self.assertRaises(AnsibleExitJson) as result: self.module.main() self.assertTrue(result.exception.args[0]['changed']) cancel_downtime_mock.assert_called_once_with(1212) + + def __downtime_with_id(self, id): + downtime = Downtime() + downtime.id = id + return downtime diff --git a/tests/unit/plugins/modules/test_django_check.py b/tests/unit/plugins/modules/test_django_check.py new file mode 100644 index 0000000000..8aec71900b --- /dev/null +++ b/tests/unit/plugins/modules/test_django_check.py @@ -0,0 +1,13 @@ +# Copyright (c) Alexei Znamensky (russoz@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.general.plugins.modules import django_check +from .helper import Helper + + +Helper.from_module(django_check, __name__) diff --git a/tests/unit/plugins/modules/test_django_check.yaml b/tests/unit/plugins/modules/test_django_check.yaml new file mode 100644 index 0000000000..6156aaa2c2 --- /dev/null +++ b/tests/unit/plugins/modules/test_django_check.yaml @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alexei Znamensky (russoz@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +--- +- id: success + input: + settings: whatever.settings + run_command_calls: + - command: [/testbin/python, -m, django, check, --no-color, --settings=whatever.settings] + environ: &env-def {environ_update: {LANGUAGE: C, LC_ALL: C}, check_rc: true} + rc: 0 + out: "whatever\n" + err: "" +- id: multiple_databases + input: + settings: whatever.settings + database: + - abc + - def + run_command_calls: + - command: [/testbin/python, -m, django, check, --no-color, --settings=whatever.settings, --database, abc, --database, def] + environ: *env-def + rc: 0 + out: "whatever\n" + err: "" diff --git a/tests/unit/plugins/modules/test_django_command.py b/tests/unit/plugins/modules/test_django_command.py new file mode 100644 index 0000000000..ffa9feb394 --- /dev/null +++ b/tests/unit/plugins/modules/test_django_command.py @@ -0,0 +1,13 @@ +# Copyright (c) Alexei Znamensky (russoz@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.general.plugins.modules import django_command +from .helper import Helper + + +Helper.from_module(django_command, __name__) diff --git a/tests/unit/plugins/modules/test_django_command.yaml b/tests/unit/plugins/modules/test_django_command.yaml new file mode 100644 index 0000000000..046dd87f03 --- /dev/null +++ b/tests/unit/plugins/modules/test_django_command.yaml @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alexei Znamensky (russoz@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +--- +- id: command_success + input: + command: check + extra_args: + - babaloo + - yaba + - daba + - doo + settings: whatever.settings + run_command_calls: + - command: [/testbin/python, -m, django, check, --no-color, --settings=whatever.settings, babaloo, yaba, daba, doo] + environ: &env-def {environ_update: {LANGUAGE: C, LC_ALL: C}, check_rc: true} + rc: 0 + out: "whatever\n" + err: "" +- id: command_fail + input: + command: check + extra_args: + - babaloo + - yaba + - daba + - doo + settings: whatever.settings + output: + failed: true + run_command_calls: + - command: [/testbin/python, -m, django, check, --no-color, --settings=whatever.settings, babaloo, yaba, daba, doo] + environ: *env-def + rc: 1 + out: "whatever\n" + err: "" diff --git a/tests/unit/plugins/modules/test_django_createcachetable.py b/tests/unit/plugins/modules/test_django_createcachetable.py new file mode 100644 index 0000000000..5a4b89c0c1 --- /dev/null +++ b/tests/unit/plugins/modules/test_django_createcachetable.py @@ -0,0 +1,13 @@ +# Copyright (c) Alexei Znamensky (russoz@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.general.plugins.modules import django_createcachetable +from .helper import Helper + + +Helper.from_module(django_createcachetable, __name__) diff --git a/tests/unit/plugins/modules/test_django_createcachetable.yaml b/tests/unit/plugins/modules/test_django_createcachetable.yaml new file mode 100644 index 0000000000..1808b163fb --- /dev/null +++ b/tests/unit/plugins/modules/test_django_createcachetable.yaml @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alexei Znamensky (russoz@gmail.com) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +--- +- id: command_success + input: + settings: whatever.settings + run_command_calls: + - command: [/testbin/python, -m, django, createcachetable, --no-color, --settings=whatever.settings, --noinput, --database=default] + environ: &env-def {environ_update: {LANGUAGE: C, LC_ALL: C}, check_rc: true} + rc: 0 + out: "whatever\n" + err: "" diff --git a/tests/unit/plugins/modules/test_homebrew.py b/tests/unit/plugins/modules/test_homebrew.py index f849b433df..d04ca4de58 100644 --- a/tests/unit/plugins/modules/test_homebrew.py +++ b/tests/unit/plugins/modules/test_homebrew.py @@ -2,23 +2,28 @@ # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later -from __future__ import (absolute_import, division, print_function) +from __future__ import absolute_import, division, print_function __metaclass__ = type from ansible_collections.community.general.tests.unit.compat import unittest -from ansible_collections.community.general.plugins.modules.homebrew import Homebrew +from ansible_collections.community.general.plugins.module_utils.homebrew import HomebrewValidate class TestHomebrewModule(unittest.TestCase): def setUp(self): - self.brew_app_names = [ - 'git-ssh', - 'awscli@1', - 'bash' + self.brew_app_names = ["git-ssh", "awscli@1", "bash"] + + self.invalid_names = [ + "git ssh", + "git*", ] def test_valid_package_names(self): for name in self.brew_app_names: - self.assertTrue(Homebrew.valid_package(name)) + self.assertTrue(HomebrewValidate.valid_package(name)) + + def test_invalid_package_names(self): + for name in self.invalid_names: + self.assertFalse(HomebrewValidate.valid_package(name)) diff --git a/tests/unit/plugins/modules/test_keycloak_identity_provider.py b/tests/unit/plugins/modules/test_keycloak_identity_provider.py index 6fd258b8a3..a893a130a5 100644 --- a/tests/unit/plugins/modules/test_keycloak_identity_provider.py +++ b/tests/unit/plugins/modules/test_keycloak_identity_provider.py @@ -23,7 +23,7 @@ from ansible.module_utils.six import StringIO @contextmanager def patch_keycloak_api(get_identity_provider, create_identity_provider=None, update_identity_provider=None, delete_identity_provider=None, get_identity_provider_mappers=None, create_identity_provider_mapper=None, update_identity_provider_mapper=None, - delete_identity_provider_mapper=None): + delete_identity_provider_mapper=None, get_realm_by_id=None): """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server Patches the `login` and `_post_json` methods @@ -55,9 +55,11 @@ def patch_keycloak_api(get_identity_provider, create_identity_provider=None, upd as mock_update_identity_provider_mapper: with patch.object(obj, 'delete_identity_provider_mapper', side_effect=delete_identity_provider_mapper) \ as mock_delete_identity_provider_mapper: - yield mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, \ - mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, \ - mock_update_identity_provider_mapper, mock_delete_identity_provider_mapper + with patch.object(obj, 'get_realm_by_id', side_effect=get_realm_by_id) \ + as mock_get_realm_by_id: + yield mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, \ + mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, \ + mock_update_identity_provider_mapper, mock_delete_identity_provider_mapper, mock_get_realm_by_id def get_response(object_with_future_response, method, get_id_call_count): @@ -200,6 +202,38 @@ class TestKeycloakIdentityProvider(ModuleTestCase): "name": "last_name" }] ] + return_value_realm_get = [ + { + 'id': 'realm-name', + 'realm': 'realm-name', + 'enabled': True, + 'identityProviders': [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + } + ] + }, + ] return_value_idp_created = [None] return_value_mapper_created = [None, None] changed = True @@ -210,15 +244,17 @@ class TestKeycloakIdentityProvider(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, - create_identity_provider=return_value_idp_created, create_identity_provider_mapper=return_value_mapper_created) \ + create_identity_provider=return_value_idp_created, create_identity_provider_mapper=return_value_mapper_created, + get_realm_by_id=return_value_realm_get) \ as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, - mock_delete_identity_provider_mapper): + mock_delete_identity_provider_mapper, mock_get_realm_by_id): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() self.assertEqual(len(mock_get_identity_provider.mock_calls), 2) self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 1) + self.assertEqual(len(mock_get_realm_by_id.mock_calls), 1) self.assertEqual(len(mock_create_identity_provider.mock_calls), 1) self.assertEqual(len(mock_create_identity_provider_mapper.mock_calls), 2) @@ -444,6 +480,68 @@ class TestKeycloakIdentityProvider(ModuleTestCase): "name": "last_name" }] ] + return_value_realm_get = [ + { + 'id': 'realm-name', + 'realm': 'realm-name', + 'enabled': True, + 'identityProviders': [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + } + ] + }, + { + 'id': 'realm-name', + 'realm': 'realm-name', + 'enabled': True, + 'identityProviders': [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + } + ] + }, + ] return_value_idp_updated = [None] return_value_mapper_updated = [None] return_value_mapper_created = [None] @@ -456,15 +554,16 @@ class TestKeycloakIdentityProvider(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, update_identity_provider=return_value_idp_updated, update_identity_provider_mapper=return_value_mapper_updated, - create_identity_provider_mapper=return_value_mapper_created) \ + create_identity_provider_mapper=return_value_mapper_created, get_realm_by_id=return_value_realm_get) \ as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, - mock_delete_identity_provider_mapper): + mock_delete_identity_provider_mapper, mock_get_realm_by_id): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() self.assertEqual(len(mock_get_identity_provider.mock_calls), 2) self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 5) + self.assertEqual(len(mock_get_realm_by_id.mock_calls), 2) self.assertEqual(len(mock_update_identity_provider.mock_calls), 1) self.assertEqual(len(mock_update_identity_provider_mapper.mock_calls), 1) self.assertEqual(len(mock_create_identity_provider_mapper.mock_calls), 1) @@ -472,6 +571,156 @@ class TestKeycloakIdentityProvider(ModuleTestCase): # Verify that the module's changed status matches what is expected self.assertIs(exec_info.exception.args[0]['changed'], changed) + def test_no_change_when_present(self): + """Update existing identity provider""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP changeme", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + 'mappers': [{ + 'name': "username", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'config': { + 'claim': "username", + 'user.attribute': "username", + 'syncMode': "INHERIT", + } + }] + } + return_value_idp_get = [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "**********", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP changeme", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + }, + ] + return_value_mappers_get = [ + [{ + 'config': { + 'claim': "username", + 'syncMode': "INHERIT", + 'user.attribute': "username" + }, + "id": "616f11ba-b9ae-42ae-bd1b-bc618741c10b", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'name': "username" + }], + [{ + 'config': { + 'claim': "username", + 'syncMode': "INHERIT", + 'user.attribute': "username" + }, + "id": "616f11ba-b9ae-42ae-bd1b-bc618741c10b", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'name': "username" + }] + ] + return_value_realm_get = [ + { + 'id': 'realm-name', + 'realm': 'realm-name', + 'enabled': True, + 'identityProviders': [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + } + ] + } + ] + return_value_idp_updated = [None] + return_value_mapper_updated = [None] + return_value_mapper_created = [None] + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, + update_identity_provider=return_value_idp_updated, update_identity_provider_mapper=return_value_mapper_updated, + create_identity_provider_mapper=return_value_mapper_created, get_realm_by_id=return_value_realm_get) \ + as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, + mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, + mock_delete_identity_provider_mapper, mock_get_realm_by_id): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_identity_provider.mock_calls), 1) + self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 2) + self.assertEqual(len(mock_get_realm_by_id.mock_calls), 1) + self.assertEqual(len(mock_update_identity_provider.mock_calls), 0) + self.assertEqual(len(mock_update_identity_provider_mapper.mock_calls), 0) + self.assertEqual(len(mock_create_identity_provider_mapper.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + def test_delete_when_absent(self): """Remove an absent identity provider""" @@ -497,7 +746,7 @@ class TestKeycloakIdentityProvider(ModuleTestCase): with patch_keycloak_api(get_identity_provider=return_value_idp_get) \ as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, - mock_delete_identity_provider_mapper): + mock_delete_identity_provider_mapper, mock_get_realm_by_id): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -560,6 +809,38 @@ class TestKeycloakIdentityProvider(ModuleTestCase): "name": "email" }] ] + return_value_realm_get = [ + { + 'id': 'realm-name', + 'realm': 'realm-name', + 'enabled': True, + 'identityProviders': [ + { + "alias": "oidc", + "displayName": "", + "internalId": "2bca4192-e816-4beb-bcba-190164eb55b8", + "providerId": "oidc", + "enabled": True, + "updateProfileFirstLoginMode": "on", + "trustEmail": False, + "storeToken": False, + "addReadTokenRoleOnCreate": False, + "authenticateByDefault": False, + "linkOnly": False, + "config": { + "validateSignature": "false", + "pkceEnabled": "false", + "tokenUrl": "https://localhost:8000", + "clientId": "asdf", + "authorizationUrl": "https://localhost:8000", + "clientAuthMethod": "client_secret_post", + "clientSecret": "real_secret", + "guiOrder": "0" + } + }, + ] + }, + ] return_value_idp_deleted = [None] changed = True @@ -569,15 +850,16 @@ class TestKeycloakIdentityProvider(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, - delete_identity_provider=return_value_idp_deleted) \ + delete_identity_provider=return_value_idp_deleted, get_realm_by_id=return_value_realm_get) \ as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, - mock_delete_identity_provider_mapper): + mock_delete_identity_provider_mapper, mock_get_realm_by_id): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() self.assertEqual(len(mock_get_identity_provider.mock_calls), 1) self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 1) + self.assertEqual(len(mock_get_realm_by_id.mock_calls), 1) self.assertEqual(len(mock_delete_identity_provider.mock_calls), 1) # Verify that the module's changed status matches what is expected diff --git a/tests/unit/plugins/modules/test_keycloak_realm_keys.py b/tests/unit/plugins/modules/test_keycloak_realm_keys.py new file mode 100644 index 0000000000..628fa54f31 --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_realm_keys.py @@ -0,0 +1,380 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from contextlib import contextmanager + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules import keycloak_realm_key + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_components=None, get_component=None, create_component=None, update_component=None, delete_component=None): + """Mock context manager for patching the methods in KeycloakAPI + """ + + obj = keycloak_realm_key.KeycloakAPI + with patch.object(obj, 'get_components', side_effect=get_components) \ + as mock_get_components: + with patch.object(obj, 'get_component', side_effect=get_component) \ + as mock_get_component: + with patch.object(obj, 'create_component', side_effect=create_component) \ + as mock_create_component: + with patch.object(obj, 'update_component', side_effect=update_component) \ + as mock_update_component: + with patch.object(obj, 'delete_component', side_effect=delete_component) \ + as mock_delete_component: + yield mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response( + object_with_future_response[method], method, get_id_call_count) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + def _create_wrapper(): + return StringIO(text_as_string) + return _create_wrapper + + +def mock_good_connection(): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), } + return patch( + 'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +class TestKeycloakRealmKeys(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealmKeys, self).setUp() + self.module = keycloak_realm_key + + def test_create_when_absent(self): + """Add a new realm key""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'parent_id': 'realm-name', + 'name': 'testkey', + 'state': 'present', + 'provider_id': 'rsa', + 'config': { + 'priority': 0, + 'enabled': True, + 'private_key': 'privatekey', + 'algorithm': 'RS256', + 'certificate': 'foo', + }, + } + return_value_component_create = [ + { + "id": "ebb7d999-60cc-4dfe-ab79-48f7bbd9d4d9", + "name": "testkey", + "providerId": "rsa", + "parentId": "90c8fef9-15f8-4d5b-8b22-44e2e1cdcd09", + "config": { + "privateKey": [ + "**********" + ], + "certificate": [ + "foo" + ], + "active": [ + "true" + ], + "priority": [ + "122" + ], + "enabled": [ + "true" + ], + "algorithm": [ + "RS256" + ] + } + } + ] + # get before_comp, get default_mapper, get after_mapper + return_value_components_get = [ + [], [], [] + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_components_get, create_component=return_value_component_create) \ + as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 1) + self.assertEqual(len(mock_update_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 0) + + # must not contain parent_id + mock_create_component.assert_called_once_with({ + 'name': 'testkey', + 'providerId': 'rsa', + 'providerType': 'org.keycloak.keys.KeyProvider', + 'config': { + 'priority': ['0'], + 'enabled': ['true'], + 'privateKey': ['privatekey'], + 'algorithm': ['RS256'], + 'certificate': ['foo'], + 'active': ['true'], + }, + }, 'realm-name') + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_create_when_present(self): + """Update existing realm key""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'parent_id': 'realm-name', + 'name': 'testkey', + 'state': 'present', + 'provider_id': 'rsa', + 'config': { + 'priority': 0, + 'enabled': True, + 'private_key': 'privatekey', + 'algorithm': 'RS256', + 'certificate': 'foo', + }, + } + return_value_components_get = [ + [ + + { + "id": "c1a957aa-3df0-4f70-9418-44202bf4ae1f", + "name": "testkey", + "providerId": "rsa", + "providerType": "org.keycloak.keys.KeyProvider", + "parentId": "90c8fef9-15f8-4d5b-8b22-44e2e1cdcd09", + "config": { + "privateKey": [ + "**********" + ], + "certificate": [ + "foo" + ], + "active": [ + "true" + ], + "priority": [ + "122" + ], + "enabled": [ + "true" + ], + "algorithm": [ + "RS256" + ] + } + }, + ], + [], + [] + ] + return_value_component_update = [ + None + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_components_get, + update_component=return_value_component_update) \ + as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 0) + self.assertEqual(len(mock_update_component.mock_calls), 1) + self.assertEqual(len(mock_delete_component.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_when_absent(self): + """Remove an absent realm key""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'parent_id': 'realm-name', + 'name': 'testkey', + 'state': 'absent', + 'provider_id': 'rsa', + 'config': { + 'priority': 0, + 'enabled': True, + 'private_key': 'privatekey', + 'algorithm': 'RS256', + 'certificate': 'foo', + }, + } + return_value_components_get = [ + [] + ] + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_components_get) \ + as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 0) + self.assertEqual(len(mock_update_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_when_present(self): + """Remove an existing realm key""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'parent_id': 'realm-name', + 'name': 'testkey', + 'state': 'absent', + 'provider_id': 'rsa', + 'config': { + 'priority': 0, + 'enabled': True, + 'private_key': 'privatekey', + 'algorithm': 'RS256', + 'certificate': 'foo', + }, + } + + return_value_components_get = [ + [ + + { + "id": "c1a957aa-3df0-4f70-9418-44202bf4ae1f", + "name": "testkey", + "providerId": "rsa", + "providerType": "org.keycloak.keys.KeyProvider", + "parentId": "90c8fef9-15f8-4d5b-8b22-44e2e1cdcd09", + "config": { + "privateKey": [ + "**********" + ], + "certificate": [ + "foo" + ], + "active": [ + "true" + ], + "priority": [ + "122" + ], + "enabled": [ + "true" + ], + "algorithm": [ + "RS256" + ] + } + }, + ], + [], + [] + ] + return_value_component_delete = [ + None + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_components_get, delete_component=return_value_component_delete) \ + as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 0) + self.assertEqual(len(mock_update_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 1) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/modules/test_keycloak_realm_keys_metadata_info.py b/tests/unit/plugins/modules/test_keycloak_realm_keys_metadata_info.py new file mode 100644 index 0000000000..14d36f6aab --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_realm_keys_metadata_info.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from contextlib import contextmanager +from itertools import count + +from ansible.module_utils.six import StringIO +from ansible_collections.community.general.plugins.modules import \ + keycloak_realm_keys_metadata_info +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, ModuleTestCase, set_module_args) + + +@contextmanager +def patch_keycloak_api(side_effect): + """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server + + Patches the `login` and `_post_json` methods + + Keyword arguments are passed to the mock object that patches `_post_json` + + No arguments are passed to the mock object that patches `login` because no tests require it + + Example:: + + with patch_ipa(return_value={}) as (mock_login, mock_post): + ... + """ + + obj = keycloak_realm_keys_metadata_info.KeycloakAPI + with patch.object(obj, "get_realm_keys_metadata_by_id", side_effect=side_effect) as obj_mocked: + yield obj_mocked + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response( + object_with_future_response[method], method, get_id_call_count + ) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response( + object_with_future_response[call_number], method, get_id_call_count + ) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs["method"] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + + def _create_wrapper(): + return StringIO(text_as_string) + + return _create_wrapper + + +def mock_good_connection(): + token_response = { + "http://keycloak.url/auth/realms/master/protocol/openid-connect/token": create_wrapper( + '{"access_token": "alongtoken"}' + ), + } + return patch( + "ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url", + side_effect=build_mocked_request(count(), token_response), + autospec=True, + ) + + +class TestKeycloakRealmRole(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealmRole, self).setUp() + self.module = keycloak_realm_keys_metadata_info + + def test_get_public_info(self): + """Get realm public info""" + + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "token": "{{ access_token }}", + "realm": "my-realm", + } + return_value = [ + { + "active": { + "AES": "aba3778d-d69d-4240-a578-a30720dbd3ca", + "HS512": "6e4fe29d-a7e4-472b-a348-298d8ae45dcc", + "RS256": "jaON84xLYg2fsKiV4p3wZag_S8MTjAp-dkpb1kRqzEs", + "RSA-OAEP": "3i_GikMqBBxtqhWXwpucxMvwl55jYlhiNIvxDTgNAEk", + }, + "keys": [ + { + "algorithm": "HS512", + "kid": "6e4fe29d-a7e4-472b-a348-298d8ae45dcc", + "providerId": "225dbe0b-3fc4-4e0d-8479-90a0cbc8adf7", + "providerPriority": 100, + "status": "ACTIVE", + "type": "OCT", + "use": "SIG", + }, + { + "algorithm": "RS256", + "certificate": "MIIC…", + "kid": "jaON84xLYg2fsKiV4p3wZag_S8MTjAp-dkpb1kRqzEs", + "providerId": "98c1ebeb-c690-4c5c-8b32-81bebe264cda", + "providerPriority": 100, + "publicKey": "MIIB…", + "status": "ACTIVE", + "type": "RSA", + "use": "SIG", + "validTo": 2034748624000, + }, + { + "algorithm": "AES", + "kid": "aba3778d-d69d-4240-a578-a30720dbd3ca", + "providerId": "99c70057-9b8d-4177-a83c-de2d081139e8", + "providerPriority": 100, + "status": "ACTIVE", + "type": "OCT", + "use": "ENC", + }, + { + "algorithm": "RSA-OAEP", + "certificate": "MIIC…", + "kid": "3i_GikMqBBxtqhWXwpucxMvwl55jYlhiNIvxDTgNAEk", + "providerId": "ab3de3fb-a32d-4be8-8324-64aa48d14c36", + "providerPriority": 100, + "publicKey": "MIIB…", + "status": "ACTIVE", + "type": "RSA", + "use": "ENC", + "validTo": 2034748625000, + }, + ], + } + ] + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(side_effect=return_value) as ( + mock_get_realm_keys_metadata_by_id + ): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + result = exec_info.exception.args[0] + self.assertIs(result["changed"], False) + self.assertEqual( + result["msg"], "Get realm keys metadata successful for ID my-realm" + ) + self.assertEqual(result["keys_metadata"], return_value[0]) + + self.assertEqual(len(mock_get_realm_keys_metadata_by_id.mock_calls), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/plugins/modules/test_keycloak_user_federation.py b/tests/unit/plugins/modules/test_keycloak_user_federation.py index 523ef9f210..81fd65e108 100644 --- a/tests/unit/plugins/modules/test_keycloak_user_federation.py +++ b/tests/unit/plugins/modules/test_keycloak_user_federation.py @@ -144,8 +144,9 @@ class TestKeycloakUserFederation(ModuleTestCase): } } ] + # get before_comp, get default_mapper, get after_mapper return_value_components_get = [ - [], [] + [], [], [] ] changed = True @@ -159,7 +160,7 @@ class TestKeycloakUserFederation(ModuleTestCase): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() - self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_components.mock_calls), 3) self.assertEqual(len(mock_get_component.mock_calls), 0) self.assertEqual(len(mock_create_component.mock_calls), 1) self.assertEqual(len(mock_update_component.mock_calls), 0) @@ -228,6 +229,7 @@ class TestKeycloakUserFederation(ModuleTestCase): } } ], + [], [] ] return_value_component_get = [ @@ -281,7 +283,7 @@ class TestKeycloakUserFederation(ModuleTestCase): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() - self.assertEqual(len(mock_get_components.mock_calls), 2) + self.assertEqual(len(mock_get_components.mock_calls), 3) self.assertEqual(len(mock_get_component.mock_calls), 1) self.assertEqual(len(mock_create_component.mock_calls), 0) self.assertEqual(len(mock_update_component.mock_calls), 1) @@ -344,7 +346,47 @@ class TestKeycloakUserFederation(ModuleTestCase): ] } return_value_components_get = [ - [], [] + [], + # exemplary default mapper created by keylocak + [ + { + "config": { + "always.read.value.from.ldap": "false", + "is.mandatory.in.ldap": "false", + "ldap.attribute": "mail", + "read.only": "true", + "user.model.attribute": "email" + }, + "id": "77e1763f-c51a-4286-bade-75577d64803c", + "name": "email", + "parentId": "e5f48aa3-b56b-4983-a8ad-2c7b8b5e77cb", + "providerId": "user-attribute-ldap-mapper", + "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + }, + ], + [ + { + "id": "2dfadafd-8b34-495f-a98b-153e71a22311", + "name": "full name", + "providerId": "full-name-ldap-mapper", + "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper", + "parentId": "eb691537-b73c-4cd8-b481-6031c26499d8", + "config": { + "ldap.full.name.attribute": [ + "cn" + ], + "read.only": [ + "true" + ], + "write.only": [ + "false" + ] + } + } + ] + ] + return_value_component_delete = [ + None ] return_value_component_create = [ { @@ -462,11 +504,11 @@ class TestKeycloakUserFederation(ModuleTestCase): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() - self.assertEqual(len(mock_get_components.mock_calls), 2) + self.assertEqual(len(mock_get_components.mock_calls), 3) self.assertEqual(len(mock_get_component.mock_calls), 0) self.assertEqual(len(mock_create_component.mock_calls), 2) self.assertEqual(len(mock_update_component.mock_calls), 0) - self.assertEqual(len(mock_delete_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 1) # Verify that the module's changed status matches what is expected self.assertIs(exec_info.exception.args[0]['changed'], changed) diff --git a/tests/unit/plugins/modules/test_keycloak_userprofile.py b/tests/unit/plugins/modules/test_keycloak_userprofile.py new file mode 100644 index 0000000000..3001201efa --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_userprofile.py @@ -0,0 +1,866 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from contextlib import contextmanager + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules import keycloak_userprofile + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_components=None, get_component=None, create_component=None, update_component=None, delete_component=None): + """Mock context manager for patching the methods in KeycloakAPI + """ + + obj = keycloak_userprofile.KeycloakAPI + with patch.object(obj, 'get_components', side_effect=get_components) as mock_get_components: + with patch.object(obj, 'get_component', side_effect=get_component) as mock_get_component: + with patch.object(obj, 'create_component', side_effect=create_component) as mock_create_component: + with patch.object(obj, 'update_component', side_effect=update_component) as mock_update_component: + with patch.object(obj, 'delete_component', side_effect=delete_component) as mock_delete_component: + yield mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response(object_with_future_response[method], method, get_id_call_count) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response(object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + def _create_wrapper(): + return StringIO(text_as_string) + return _create_wrapper + + +def mock_good_connection(): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), + } + return patch( + 'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +class TestKeycloakUserprofile(ModuleTestCase): + def setUp(self): + super(TestKeycloakUserprofile, self).setUp() + self.module = keycloak_userprofile + + def test_create_when_absent(self): + """Add a new userprofile""" + + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "auth_realm": "master", + "auth_username": "admin", + "auth_password": "admin", + "parent_id": "realm-name", + "state": "present", + "provider_id": "declarative-user-profile", + "config": { + "kc_user_profile_config": [ + { + "attributes": [ + { + "annotations": {}, + "displayName": "${username}", + "multivalued": False, + "name": "username", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": None, + "validations": { + "length": { + "max": 255, + "min": 3 + }, + "up_username_not_idn_homograph": {}, + "username_prohibited_characters": {} + } + }, + { + "annotations": {}, + "displayName": "${email}", + "multivalued": False, + "name": "email", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": { + "roles": [ + "user" + ] + }, + "validations": { + "email": {}, + "length": { + "max": 255 + } + } + }, + { + "annotations": {}, + "displayName": "${firstName}", + "multivalued": False, + "name": "firstName", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": { + "roles": [ + "user" + ] + }, + "validations": { + "length": { + "max": 255 + }, + "person_name_prohibited_characters": {} + } + }, + { + "annotations": {}, + "displayName": "${lastName}", + "multivalued": False, + "name": "lastName", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": { + "roles": [ + "user" + ] + }, + "validations": { + "length": { + "max": 255 + }, + "person_name_prohibited_characters": {} + } + } + ], + "groups": [ + { + "displayDescription": "Attributes, which refer to user metadata", + "displayHeader": "User metadata", + "name": "user-metadata" + } + ], + } + ] + } + } + return_value_component_create = [ + { + "id": "4ba43451-6bb4-4b50-969f-e890539f15e3", + "parentId": "realm-name", + "providerId": "declarative-user-profile", + "providerType": "org.keycloak.userprofile.UserProfileProvider", + "config": { + "kc.user.profile.config": [ + { + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "validations": { + "length": { + "min": 3, + "max": 255 + }, + "username-prohibited-characters": {}, + "up-username-not-idn-homograph": {} + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {}, + "required": None + }, + { + "name": "email", + "displayName": "${email}", + "validations": { + "email": {}, + "length": { + "max": 255 + } + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + }, + { + "name": "firstName", + "displayName": "${firstName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + }, + { + "name": "lastName", + "displayName": "${lastName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + + + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata", + } + ], + } + ] + } + } + ] + return_value_get_components_get = [ + [], [] + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_get_components_get, create_component=return_value_component_create) as ( + mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 1) + self.assertEqual(len(mock_update_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_create_when_present(self): + """Update existing userprofile""" + + module_args = { + "auth_keycloak_url": "http://keycloak.url/auth", + "auth_realm": "master", + "auth_username": "admin", + "auth_password": "admin", + "parent_id": "realm-name", + "state": "present", + "provider_id": "declarative-user-profile", + "config": { + "kc_user_profile_config": [ + { + "attributes": [ + { + "annotations": {}, + "displayName": "${username}", + "multivalued": False, + "name": "username", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": None, + "validations": { + "length": { + "max": 255, + "min": 3 + }, + "up_username_not_idn_homograph": {}, + "username_prohibited_characters": {} + } + }, + { + "annotations": {}, + "displayName": "${email}", + "multivalued": False, + "name": "email", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": { + "roles": [ + "user" + ] + }, + "validations": { + "email": {}, + "length": { + "max": 255 + } + } + }, + { + "annotations": {}, + "displayName": "${firstName}", + "multivalued": False, + "name": "firstName", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": { + "roles": [ + "user" + ] + }, + "validations": { + "length": { + "max": 255 + }, + "person_name_prohibited_characters": {} + } + }, + { + "annotations": {}, + "displayName": "${lastName}", + "multivalued": False, + "name": "lastName", + "permissions": { + "edit": [ + "admin", + "user" + ], + "view": [ + "admin", + "user" + ] + }, + "required": { + "roles": [ + "user" + ] + }, + "validations": { + "length": { + "max": 255 + }, + "person_name_prohibited_characters": {} + } + } + ], + "groups": [ + { + "displayDescription": "Attributes, which refer to user metadata", + "displayHeader": "User metadata", + "name": "user-metadata" + } + ], + } + ] + } + } + return_value_get_components_get = [ + [ + { + "id": "4ba43451-6bb4-4b50-969f-e890539f15e3", + "parentId": "realm-1", + "providerId": "declarative-user-profile", + "providerType": "org.keycloak.userprofile.UserProfileProvider", + "config": { + "kc.user.profile.config": [ + { + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "validations": { + "length": { + "min": 3, + "max": 255 + }, + "username-prohibited-characters": {}, + "up-username-not-idn-homograph": {} + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {}, + "required": None + }, + { + "name": "email", + "displayName": "${email}", + "validations": { + "email": {}, + "length": { + "max": 255 + } + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + }, + { + "name": "firstName", + "displayName": "${firstName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + }, + { + "name": "lastName", + "displayName": "${lastName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata", + } + ], + } + ] + } + } + ], + [] + ] + return_value_component_update = [ + None + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_get_components_get, + update_component=return_value_component_update) as ( + mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 0) + self.assertEqual(len(mock_update_component.mock_calls), 1) + self.assertEqual(len(mock_delete_component.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_when_absent(self): + """Remove an absent userprofile""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'parent_id': 'realm-name', + 'provider_id': 'declarative-user-profile', + 'state': 'absent', + } + return_value_get_components_get = [ + [] + ] + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_get_components_get) as ( + mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 0) + self.assertEqual(len(mock_update_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_when_present(self): + """Remove an existing userprofile""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'parent_id': 'realm-name', + 'provider_id': 'declarative-user-profile', + 'state': 'absent', + } + return_value_get_components_get = [ + [ + { + "id": "4ba43451-6bb4-4b50-969f-e890539f15e3", + "parentId": "realm-1", + "providerId": "declarative-user-profile", + "providerType": "org.keycloak.userprofile.UserProfileProvider", + "config": { + "kc.user.profile.config": [ + { + "attributes": [ + { + "name": "username", + "displayName": "${username}", + "validations": { + "length": { + "min": 3, + "max": 255 + }, + "username-prohibited-characters": {}, + "up-username-not-idn-homograph": {} + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {}, + "required": None + }, + { + "name": "email", + "displayName": "${email}", + "validations": { + "email": {}, + "length": { + "max": 255 + } + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + }, + { + "name": "firstName", + "displayName": "${firstName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + }, + { + "name": "lastName", + "displayName": "${lastName}", + "validations": { + "length": { + "max": 255 + }, + "person-name-prohibited-characters": {} + }, + "required": { + "roles": [ + "user" + ] + }, + "permissions": { + "view": [ + "admin", + "user" + ], + "edit": [ + "admin", + "user" + ] + }, + "multivalued": False, + "annotations": {} + } + ], + "groups": [ + { + "name": "user-metadata", + "displayHeader": "User metadata", + "displayDescription": "Attributes, which refer to user metadata", + } + ], + } + ] + } + } + ], + [] + ] + return_value_component_delete = [ + None + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_components=return_value_get_components_get, delete_component=return_value_component_delete) as ( + mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_components.mock_calls), 1) + self.assertEqual(len(mock_get_component.mock_calls), 0) + self.assertEqual(len(mock_create_component.mock_calls), 0) + self.assertEqual(len(mock_update_component.mock_calls), 0) + self.assertEqual(len(mock_delete_component.mock_calls), 1) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/plugins/modules/test_puppet.yaml b/tests/unit/plugins/modules/test_puppet.yaml index 308be97975..7909403cfb 100644 --- a/tests/unit/plugins/modules/test_puppet.yaml +++ b/tests/unit/plugins/modules/test_puppet.yaml @@ -190,3 +190,35 @@ rc: 0 out: "" err: "" +- id: puppet_agent_waitforlock + input: + waitforlock: 30 + output: + changed: false + run_command_calls: + - command: [/testbin/puppet, config, print, agent_disabled_lockfile] + environ: *env-def + rc: 0 + out: "blah, anything" + err: "" + - command: + - /testbin/timeout + - -s + - "9" + - 30m + - /testbin/puppet + - agent + - --onetime + - --no-daemonize + - --no-usecacheonfailure + - --no-splay + - --detailed-exitcodes + - --verbose + - --color + - "0" + - --waitforlock + - "30" + environ: *env-def + rc: 0 + out: "" + err: "" diff --git a/tests/unit/plugins/modules/test_redis_info.py b/tests/unit/plugins/modules/test_redis_info.py index cdc78680e5..831b8f4052 100644 --- a/tests/unit/plugins/modules/test_redis_info.py +++ b/tests/unit/plugins/modules/test_redis_info.py @@ -55,6 +55,8 @@ class TestRedisInfoModule(ModuleTestCase): 'password': None, 'ssl': False, 'ssl_ca_certs': None, + 'ssl_certfile': None, + 'ssl_keyfile': None, 'ssl_cert_reqs': 'required'},)) self.assertEqual(result.exception.args[0]['info']['redis_version'], '999.999.999') @@ -74,6 +76,8 @@ class TestRedisInfoModule(ModuleTestCase): 'password': 'PASS', 'ssl': False, 'ssl_ca_certs': None, + 'ssl_certfile': None, + 'ssl_keyfile': None, 'ssl_cert_reqs': 'required'},)) self.assertEqual(result.exception.args[0]['info']['redis_version'], '999.999.999') @@ -87,6 +91,8 @@ class TestRedisInfoModule(ModuleTestCase): 'login_password': 'PASS', 'tls': True, 'ca_certs': '/etc/ssl/ca.pem', + 'client_cert_file': '/etc/ssl/client.pem', + 'client_key_file': '/etc/ssl/client.key', 'validate_certs': False }) self.module.main() @@ -96,6 +102,8 @@ class TestRedisInfoModule(ModuleTestCase): 'password': 'PASS', 'ssl': True, 'ssl_ca_certs': '/etc/ssl/ca.pem', + 'ssl_certfile': '/etc/ssl/client.pem', + 'ssl_keyfile': '/etc/ssl/client.key', 'ssl_cert_reqs': None},)) self.assertEqual(result.exception.args[0]['info']['redis_version'], '999.999.999') diff --git a/tests/unit/plugins/modules/test_wdc_redfish_command.py b/tests/unit/plugins/modules/test_wdc_redfish_command.py index 332b976f70..0775ac73dd 100644 --- a/tests/unit/plugins/modules/test_wdc_redfish_command.py +++ b/tests/unit/plugins/modules/test_wdc_redfish_command.py @@ -289,7 +289,7 @@ def mock_get_firmware_inventory_version_1_2_3(*args, **kwargs): } -ERROR_MESSAGE_UNABLE_TO_EXTRACT_BUNDLE_VERSION = "Unable to extract bundle version or multi-tenant status from update image tarfile" +ERROR_MESSAGE_UNABLE_TO_EXTRACT_BUNDLE_VERSION = "Unable to extract bundle version or multi-tenant status or generation from update image file" ACTION_WAS_SUCCESSFUL_MESSAGE = "Action was successful" diff --git a/tests/unit/plugins/plugin_utils/test_unsafe.py b/tests/unit/plugins/plugin_utils/test_unsafe.py new file mode 100644 index 0000000000..3f35ee9337 --- /dev/null +++ b/tests/unit/plugins/plugin_utils/test_unsafe.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024, Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible.utils.unsafe_proxy import AnsibleUnsafe + +from ansible_collections.community.general.plugins.plugin_utils.unsafe import ( + make_unsafe, +) + + +TEST_MAKE_UNSAFE = [ + ( + u'text', + [], + [ + (), + ], + ), + ( + u'{{text}}', + [ + (), + ], + [], + ), + ( + b'text', + [], + [ + (), + ], + ), + ( + b'{{text}}', + [ + (), + ], + [], + ), + ( + { + 'skey': 'value', + 'ukey': '{{value}}', + 1: [ + 'value', + '{{value}}', + { + 1.0: '{{value}}', + 2.0: 'value', + }, + ], + }, + [ + ('ukey', ), + (1, 1), + (1, 2, 1.0), + ], + [ + ('skey', ), + (1, 0), + (1, 2, 2.0), + ], + ), + ( + ['value', '{{value}}'], + [ + (1, ), + ], + [ + (0, ), + ], + ), +] + + +@pytest.mark.parametrize("value, check_unsafe_paths, check_safe_paths", TEST_MAKE_UNSAFE) +def test_make_unsafe(value, check_unsafe_paths, check_safe_paths): + unsafe_value = make_unsafe(value) + assert unsafe_value == value + for check_path in check_unsafe_paths: + obj = unsafe_value + for elt in check_path: + obj = obj[elt] + assert isinstance(obj, AnsibleUnsafe) + for check_path in check_safe_paths: + obj = unsafe_value + for elt in check_path: + obj = obj[elt] + assert not isinstance(obj, AnsibleUnsafe) + + +def test_make_unsafe_dict_key(): + value = { + b'test': 1, + u'test': 2, + } + unsafe_value = make_unsafe(value) + assert unsafe_value == value + for obj in unsafe_value: + assert not isinstance(obj, AnsibleUnsafe) + + value = { + b'{{test}}': 1, + u'{{test}}': 2, + } + unsafe_value = make_unsafe(value) + assert unsafe_value == value + for obj in unsafe_value: + assert isinstance(obj, AnsibleUnsafe) + + +def test_make_unsafe_set(): + value = set([b'test', u'test']) + unsafe_value = make_unsafe(value) + assert unsafe_value == value + for obj in unsafe_value: + assert not isinstance(obj, AnsibleUnsafe) + + value = set([b'{{test}}', u'{{test}}']) + unsafe_value = make_unsafe(value) + assert unsafe_value == value + for obj in unsafe_value: + assert isinstance(obj, AnsibleUnsafe)